Skip to content

feat: expose caught-up-to-tail signal on read sessions#88

Open
infiniteregrets wants to merge 1 commit into
mainfrom
codex/caught-up-tail
Open

feat: expose caught-up-to-tail signal on read sessions#88
infiniteregrets wants to merge 1 commit into
mainfrom
codex/caught-up-tail

Conversation

@infiniteregrets

Copy link
Copy Markdown
Member

No description provided.

@infiniteregrets
infiniteregrets marked this pull request as ready for review July 19, 2026 17:09
@infiniteregrets
infiniteregrets requested a review from a team as a code owner July 19, 2026 17:09
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR wraps the existing read_session async generator in a new ReadSession class that exposes a caught_up() awaitable and is_caught_up() predicate, allowing callers to know when the session has consumed all records through the latest reported tail. A new ReadSessionClosedError is raised when the session ends before catching up.

  • ReadSession is an AsyncIterator[ReadBatch] and async context manager backed by a managed asyncio.Task; it handles cancellation propagation, waiter lifecycle across reconnects, and error normalization consistently with the Go SDK at s2/read_session.go.
  • _s2s/_read_session.py is refactored to yield _ReadSessionUpdate objects; _caught_up_tail computes caught-up state on the original batch before command-record filtering so filtered records still count toward progress.
  • Reconnect retries now emit an empty _ReadSessionUpdate() to reset the caught-up state, mirroring Go's setBehind() on each new connection attempt.

Confidence Score: 4/5

Safe to merge; the core logic is correct and matches the Go SDK caught-up semantics closely.

The ReadSession implementation is carefully written — cancellation propagation, waiter lifecycle across reconnects, error normalization, and the delivery-consumed handshake all look correct. Both findings are non-blocking: _mark_behind() is called on every update including heartbeats, creating a transient reset invisible in practice due to single-threaded asyncio; and the close()-before-start path is correct but worth a clarifying comment for future maintainers.

src/s2_sdk/_read_session.py — the _run() loop and close() early-exit path are the most complex sections and deserve the most attention on future changes.

Important Files Changed

Filename Overview
src/s2_sdk/_read_session.py New ReadSession class providing async context manager, iterator, is_caught_up(), and caught_up() awaitable. Logic for task lifecycle, waiter management, and cancellation propagation is carefully written; one subtle concern with _mark_behind on every update (including heartbeats with a tail) exists but is benign in single-threaded asyncio.
src/s2_sdk/_s2s/_read_session.py Refactored to yield _ReadSessionUpdate instead of ReadBatch; added _caught_up_tail helper that correctly computes caught-up state before command record filtering; reconnect signals now yield empty _ReadSessionUpdate().
src/s2_sdk/_ops.py read_session changed from async generator to sync function returning ReadSession; @Fallible decorator now wraps it as sync_wrapper, which is correct since construction is synchronous and iteration exceptions are normalized inside ReadSession._run().
src/s2_sdk/_exceptions.py Adds ReadSessionClosedError as a S2ClientError subclass with correct module assignment for clean public API exposure.
tests/test_read_session.py Comprehensive unit tests for ReadSession covering caught-up lifecycle, heartbeat semantics, reconnect survival, error propagation, cancellation handling, and close behavior; asyncio_mode=auto in pytest.ini makes @pytest.mark.asyncio decorators unnecessary.
tests/test_correctness.py Updated to use async context manager pattern for read_session and adds a new correctness test for caught-up reporting after delivering tail.
src/s2_sdk/init.py Correctly exports ReadSession and ReadSessionClosedError in both the import list and all.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant ReadSession
    participant _run task
    participant run_read_session

    User->>ReadSession: "stream.read_session(start=...)"
    Note over ReadSession: Stores generator ref, no task yet

    User->>ReadSession: aenter / aiter / caught_up()
    ReadSession->ReadSession: "_ensure_started() -> create_task(_run)"

    _run task->>run_read_session: aiter(updates)
    run_read_session-->>_run task: _ReadSessionUpdate(batch=None) [reconnect]
    _run task->ReadSession: _mark_behind() -> is_caught_up=False

    run_read_session-->>_run task: _ReadSessionUpdate(batch, caught_up_tail)
    _run task->ReadSession: _mark_behind()
    _run task->>ReadSession._deliveries: put(delivery)
    _run task->>_run task: await delivery.consumed

    User->>ReadSession: await anext()
    ReadSession->>ReadSession._deliveries: "get() -> delivery"
    ReadSession->ReadSession: "_mark_caught_up(tail) -> resolve waiters"
    ReadSession->>_run task: delivery.consumed.set()
    ReadSession-->>User: batch

    User->>ReadSession: await caught_up()
    ReadSession-->>User: StreamPosition (immediately if caught up)

    User->>ReadSession: aclose() / aexit
    ReadSession->>_run task: task.cancel()
    _run task->>run_read_session: aclose()
    _run task->ReadSession: _finish(error=None)
    _run task->ReadSession: _signal_end() -> drain queue, put _END
    ReadSession-->>User: (closed)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant ReadSession
    participant _run task
    participant run_read_session

    User->>ReadSession: "stream.read_session(start=...)"
    Note over ReadSession: Stores generator ref, no task yet

    User->>ReadSession: aenter / aiter / caught_up()
    ReadSession->ReadSession: "_ensure_started() -> create_task(_run)"

    _run task->>run_read_session: aiter(updates)
    run_read_session-->>_run task: _ReadSessionUpdate(batch=None) [reconnect]
    _run task->ReadSession: _mark_behind() -> is_caught_up=False

    run_read_session-->>_run task: _ReadSessionUpdate(batch, caught_up_tail)
    _run task->ReadSession: _mark_behind()
    _run task->>ReadSession._deliveries: put(delivery)
    _run task->>_run task: await delivery.consumed

    User->>ReadSession: await anext()
    ReadSession->>ReadSession._deliveries: "get() -> delivery"
    ReadSession->ReadSession: "_mark_caught_up(tail) -> resolve waiters"
    ReadSession->>_run task: delivery.consumed.set()
    ReadSession-->>User: batch

    User->>ReadSession: await caught_up()
    ReadSession-->>User: StreamPosition (immediately if caught up)

    User->>ReadSession: aclose() / aexit
    ReadSession->>_run task: task.cancel()
    _run task->>run_read_session: aclose()
    _run task->ReadSession: _finish(error=None)
    _run task->ReadSession: _signal_end() -> drain queue, put _END
    ReadSession-->>User: (closed)
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/s2_sdk/_read_session.py:155-166
**`_mark_behind()` called unconditionally, including for caught-up heartbeats**

Every iteration of the inner loop calls `_mark_behind()` before inspecting the update. For heartbeat updates — an empty batch with a non-`None` `caught_up_tail` — this causes the sequence `_mark_behind()``_mark_caught_up()` within the same event-loop tick. The Go SDK's equivalent `setBehind()` is [skipped for pure heartbeats](https://github.com/s2-streamstore/s2-sdk-go/blob/HEAD/s2/read.go#L566-L568) (`!caughtUp || hasRecords`). Because asyncio is single-threaded, the transient `False` state is never observable through `is_caught_up()`, but the docstring's stated semantics ("A later batch that does not reach a reported tail or a reconnect resets it") do not precisely match the code: every batch, including heartbeats with a tail, momentarily resets the flag. This could silently break if the invariant is ever relied upon in a future refactor.

### Issue 2 of 2
src/s2_sdk/_read_session.py:93-113
**`close()` with no task skips `_is_caught_up` reset on error path**

When `close()` is called before iteration starts (`_task is None`), `_finish()` is called with no argument, which does NOT clear `_is_caught_up`. Since the task never ran this is always `False` today, so no observable issue exists. More noteworthy: `_finish` sets `rejection = error or _session_closed_error()` where `error` is `None`, so subsequent calls to `caught_up()` correctly raise `ReadSessionClosedError`. The path is correct but a brief comment explaining why `_finish()` is called without an error argument here would help future maintainers.

Reviews (1): Last reviewed commit: "feat: expose caught-up-to-tail signal on..." | Re-trigger Greptile

Comment on lines +155 to +166
async def _run(self) -> None:
error: BaseException | None = None
cancelled = False
updates = aiter(self._updates)
try:
async for update in updates:
self._mark_behind()
batch = update.batch
if batch is None or not batch.records:
if update.caught_up_tail is not None:
self._mark_caught_up(update.caught_up_tail)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _mark_behind() called unconditionally, including for caught-up heartbeats

Every iteration of the inner loop calls _mark_behind() before inspecting the update. For heartbeat updates — an empty batch with a non-None caught_up_tail — this causes the sequence _mark_behind()_mark_caught_up() within the same event-loop tick. The Go SDK's equivalent setBehind() is skipped for pure heartbeats (!caughtUp || hasRecords). Because asyncio is single-threaded, the transient False state is never observable through is_caught_up(), but the docstring's stated semantics ("A later batch that does not reach a reported tail or a reconnect resets it") do not precisely match the code: every batch, including heartbeats with a tail, momentarily resets the flag. This could silently break if the invariant is ever relied upon in a future refactor.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/s2_sdk/_read_session.py
Line: 155-166

Comment:
**`_mark_behind()` called unconditionally, including for caught-up heartbeats**

Every iteration of the inner loop calls `_mark_behind()` before inspecting the update. For heartbeat updates — an empty batch with a non-`None` `caught_up_tail` — this causes the sequence `_mark_behind()``_mark_caught_up()` within the same event-loop tick. The Go SDK's equivalent `setBehind()` is [skipped for pure heartbeats](https://github.com/s2-streamstore/s2-sdk-go/blob/HEAD/s2/read.go#L566-L568) (`!caughtUp || hasRecords`). Because asyncio is single-threaded, the transient `False` state is never observable through `is_caught_up()`, but the docstring's stated semantics ("A later batch that does not reach a reported tail or a reconnect resets it") do not precisely match the code: every batch, including heartbeats with a tail, momentarily resets the flag. This could silently break if the invariant is ever relied upon in a future refactor.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +93 to +113
async def close(self) -> None:
"""Close the session."""
if self._closed:
return
if self._task is None:
self._finish()
self._signal_end()
return
current = asyncio.current_task()
cancellation_count = current.cancelling() if current is not None else 0
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
finally:
if not self._closed:
self._finish()
self._signal_end()
if current is not None and current.cancelling() > cancellation_count:
raise asyncio.CancelledError

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 close() with no task skips _is_caught_up reset on error path

When close() is called before iteration starts (_task is None), _finish() is called with no argument, which does NOT clear _is_caught_up. Since the task never ran this is always False today, so no observable issue exists. More noteworthy: _finish sets rejection = error or _session_closed_error() where error is None, so subsequent calls to caught_up() correctly raise ReadSessionClosedError. The path is correct but a brief comment explaining why _finish() is called without an error argument here would help future maintainers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/s2_sdk/_read_session.py
Line: 93-113

Comment:
**`close()` with no task skips `_is_caught_up` reset on error path**

When `close()` is called before iteration starts (`_task is None`), `_finish()` is called with no argument, which does NOT clear `_is_caught_up`. Since the task never ran this is always `False` today, so no observable issue exists. More noteworthy: `_finish` sets `rejection = error or _session_closed_error()` where `error` is `None`, so subsequent calls to `caught_up()` correctly raise `ReadSessionClosedError`. The path is correct but a brief comment explaining why `_finish()` is called without an error argument here would help future maintainers.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant