streamcast 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
streamcast/__init__.py ADDED
@@ -0,0 +1,128 @@
1
+ """A replayable WebSocket multicaster.
2
+
3
+ One upstream stream in, appended to a litelink log — an Iceberg table on disk
4
+ — and broadcast to any number of downstream subscribers. One process holds the
5
+ upstream connection; every consumer reads from it and receives the same bytes
6
+ in the same order, from one `encode` call.
7
+
8
+ A streamcast server is a Python WebSocket tickerplant: a process that captures
9
+ a feed, optionally writes it to a log, and publishes it to registered
10
+ subscribers (https://code.kx.com/q/architecture/).
11
+
12
+ With a log attached, every message is durable *before* any subscriber sees it,
13
+ so a consumer that falls behind, crashes or restarts reconnects with the last
14
+ offset it processed and the server replays the gap before switching it to
15
+ live, with no window in which a message is in neither place. Without one the
16
+ fan-out is identical, offsets are `null`, and `?offset=` is refused.
17
+ `docs/SPEC.md` §3 argues that partition; `Stream` enforces it.
18
+
19
+ **The API is `websockets`, with three deviations.** `serve` and `connect`
20
+ have the same shapes and pass their keywords through, and `serve` returns an
21
+ object that proxies `websockets.Server`. What differs: iterating a
22
+ subscription yields `(offset, row)` rather than `message`, because the offset
23
+ is what makes a reconnect a resume; a subscription is read-only, with no
24
+ `send` rather than a `send` that raises; and `compression` defaults to None
25
+ here where `websockets` defaults to `"deflate"`, because permessage-deflate
26
+ compresses once per subscriber a frame this encodes once.
27
+
28
+ **The schema is yours, declared in JSON Schema.** streamcast declares no
29
+ columns — the log is an ordinary litelink table with whatever shape you gave
30
+ it, so every column prunes, compresses, and is queryable from any Iceberg
31
+ engine. The wire is JSON, so the columns are declared in JSON Schema and
32
+ converted here (`to_arrow`, `from_arrow`); `send` takes a row, subscribers
33
+ receive that row, and the parse happens once at the publisher rather than once
34
+ per consumer.
35
+
36
+ .. code-block:: python
37
+
38
+ # server — `new` creates the log at data/trades, or opens what is there
39
+ stream = streamcast.Stream.new("trades", root="data", schema=SCHEMA,
40
+ sort_by=("event_ts",))
41
+
42
+ # Fan-out, sealing, compaction and WAL shipping: all of it, one call.
43
+ async with streamcast.serve(stream, "localhost", 8765):
44
+ async for frame in upstream:
45
+ await stream.send(parse(frame)) # a row
46
+
47
+ # consumer — `cursor` keeps the resume point, so a restart is a resume
48
+ async with streamcast.connect(
49
+ "ws://localhost:8765/trades", cursor=".trades.offset", catch_up=True
50
+ ) as sub:
51
+ async for offset, msg in sub:
52
+ ...
53
+
54
+ **`serve` starts everything the stream needs.** A log that nobody seals grows
55
+ for ever, and a WAL nobody ships is not replicated, so `serve` runs the
56
+ maintainer in a subprocess and litestream as an flock-guarded sidecar. Both
57
+ are keyword-controlled (`maintain=`, `replicate=`) for when you run your own.
58
+
59
+ **The object model is two classes and two functions.** `Stream` is the
60
+ broadcast — offsets, subscribers, replay — and holds no socket. `serve` puts
61
+ it behind a port; `connect` reads it. `Subscription` is what a consumer holds,
62
+ and it is read-only: it has no `send`, rather than a `send` that raises, for
63
+ the reason litelink's read handles have no `append`.
64
+
65
+ **Recovery is three keywords on `connect`.** `cursor=` keeps the last handled
66
+ offset on disk; `cursor_uri=` ships it to object storage so a consumer can
67
+ resume on another box; `catch_up=True` reads the gap from the log's archive
68
+ when a consumer has fallen past what the server will replay, then picks the
69
+ socket up where the archive ended.
70
+
71
+ ``EARLIEST`` is the offset that means "everything the log still holds".
72
+
73
+ Every frame on the wire is JSON text — the greeting, then an ``[offset, msg]``
74
+ pair per message — so ``wscat ws://localhost:8765/trades?offset=0`` is a working
75
+ subscriber with no client library at all. **``msg`` is the row the publisher
76
+ sent and nothing else**: no offset key, no injected metadata, so a subscriber
77
+ can log it, forward it or append it to another stream whole.
78
+ """
79
+
80
+ from importlib.metadata import PackageNotFoundError, version
81
+
82
+ from litelink import S3Options
83
+
84
+ from streamcast._catchup import CatchUpUnavailable
85
+ from streamcast._client import Subscription, connect
86
+ from streamcast._cursor import Cursor
87
+ from streamcast._errors import (
88
+ Close,
89
+ NotReplayable,
90
+ ProtocolError,
91
+ StreamcastError,
92
+ StreamNotFound,
93
+ TooSlow,
94
+ )
95
+ from streamcast._maintain import Maintain
96
+ from streamcast._protocol import EARLIEST, Greeting
97
+ from streamcast._schema import from_arrow, to_arrow
98
+ from streamcast._server import serve
99
+ from streamcast._stream import MAX_BACKLOG, MAX_REPLAY, Stream
100
+
101
+ try:
102
+ __version__ = version("streamcast")
103
+ except PackageNotFoundError: # a source tree that was never installed
104
+ __version__ = "0.0.0"
105
+
106
+ __all__ = [
107
+ "EARLIEST",
108
+ "MAX_BACKLOG",
109
+ "MAX_REPLAY",
110
+ "CatchUpUnavailable",
111
+ "Close",
112
+ "Cursor",
113
+ "Greeting",
114
+ "Maintain",
115
+ "S3Options",
116
+ "NotReplayable",
117
+ "ProtocolError",
118
+ "Stream",
119
+ "StreamNotFound",
120
+ "StreamcastError",
121
+ "Subscription",
122
+ "TooSlow",
123
+ "__version__",
124
+ "connect",
125
+ "from_arrow",
126
+ "serve",
127
+ "to_arrow",
128
+ ]
streamcast/__main__.py ADDED
@@ -0,0 +1,42 @@
1
+ """`python -m streamcast <command>` — the out-of-process half of the library.
2
+
3
+ One command today. It exists as a package entry point rather than as
4
+ `python -m streamcast._maintain` because that form re-executes a module the
5
+ parent has already imported — `__init__` reaches `_maintain` through `_server`
6
+ — and CPython warns about it:
7
+
8
+ RuntimeWarning: 'streamcast._maintain' found in sys.modules after import
9
+ of package 'streamcast', but prior to execution
10
+
11
+ Benign here, since that module is constants and functions, but a warning on
12
+ every maintainer start is noise in exactly the logs an operator reads when
13
+ something is wrong. `__main__` is imported once, by nothing else.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from streamcast._maintain import main as _maintain
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ argv = sys.argv[1:] if argv is None else argv
25
+ if argv and argv[0] == "maintain":
26
+ return _maintain(argv[1:])
27
+
28
+ print(
29
+ "usage: python -m streamcast maintain --root PATH --name NAME\n"
30
+ "\n"
31
+ "Sweeps a stream's litelink log: seals the buffer into Parquet, then\n"
32
+ "compacts, evicts and expires. `streamcast.serve(maintain=True)`\n"
33
+ "starts one of these per stream and stops it on close, so running it\n"
34
+ "by hand is for a deployment that passed `maintain=False`.",
35
+ file=sys.stderr,
36
+ )
37
+
38
+ return 2
39
+
40
+
41
+ if __name__ == "__main__":
42
+ raise SystemExit(main())
streamcast/_catchup.py ADDED
@@ -0,0 +1,448 @@
1
+ """Reading the gap out of the archive when a consumer has fallen too far behind.
2
+
3
+ A server refuses `?offset=` that is further back than `max_replay`, and until
4
+ this existed the answer was "read the log yourself" — which means the consumer
5
+ has to know where the archive is, open litelink, scan it in batches without
6
+ running out of memory, convert rows, and then work out where to resume the
7
+ socket. That is orchestration nobody wants to write twice, so `catch_up=True`
8
+ does it:
9
+
10
+ async with streamcast.connect(uri, cursor=path, catch_up=True) as stream:
11
+ async for offset, msg in stream:
12
+ ...
13
+
14
+ The consumer sees one stream. Underneath, the rows below the server's window
15
+ come from object storage and the rest come from the socket.
16
+
17
+ **No socket is held while the archive is read.** The first version of this
18
+ opened the live connection at the archive's frontier first, reasoning that it
19
+ closed the gap by construction. It does — and it also makes the server queue
20
+ for a subscriber that will not read a message until it has streamed millions
21
+ of rows out of object storage. `max_backlog` is 8,192, so the connection would
22
+ be dropped with `TooSlow` before the catch-up finished: a recovery that
23
+ guaranteed its own failure on exactly the consumers that needed it.
24
+
25
+ So the archive is read with nothing connected, and the socket is opened after,
26
+ at the offset the archive actually reached. That leaves a window — rows
27
+ published while the gap was being read — and the server still holds those,
28
+ because they are inside its replay window. If they are not, the archive has
29
+ grown in the meantime, so the whole thing is a LOOP: read, try to connect,
30
+ and if the server still says too old, read the newly archived rows and try
31
+ again. It converges once the archive gets within the server's window, and
32
+ `catch_up_retries` bounds it for when that never happens.
33
+
34
+ **Memory is one batch.** The scan is a `RecordBatchReader` and every blocking
35
+ call crosses into a thread, exactly as the server's replay does — a catch-up
36
+ of ten million rows holds one batch, not ten million.
37
+
38
+ **What it cannot fix.** If the archive's frontier is itself below the server's
39
+ window, there is a range nothing holds: the server has forgotten it and the
40
+ archive never received it. That is reported with both numbers rather than
41
+ half-served, because a consumer that silently resumed above the gap would have
42
+ lost data and been told it recovered.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import asyncio
48
+ from typing import TYPE_CHECKING, Any, Final
49
+
50
+ import litelink
51
+
52
+ from streamcast import _log
53
+ from streamcast._errors import NotReplayable, StreamcastError
54
+
55
+ if TYPE_CHECKING:
56
+ from collections.abc import AsyncGenerator, Awaitable, Callable
57
+
58
+ from litelink import RemoteReadHandle, S3Options
59
+
60
+ # The `why` values a catch-up can answer. `not_durable`, `empty` and `ahead`
61
+ # are not gaps in an archive — they are a stream with no log, a log with
62
+ # nothing in it, and a cursor from the future — and no amount of reading
63
+ # object storage fixes any of them.
64
+ RECOVERABLE: Final = frozenset({"too_old", "evicted"})
65
+
66
+ CATCH_UP_RETRIES: Final = 3
67
+ """Rounds of read-the-archive-then-connect before giving up.
68
+
69
+ Each round narrows the gap, because the archive grows while the last one was
70
+ read. Three is enough for a server syncing on any ordinary interval and small
71
+ enough that a stream published faster than it is archived fails quickly with
72
+ a message saying so, rather than reading object storage for ever.
73
+ """
74
+
75
+
76
+ class CatchUpUnavailable(StreamcastError):
77
+ """The gap cannot be read from object storage, and why.
78
+
79
+ Its own type because the caller's next move is specific and usually
80
+ administrative — credentials, a bucket policy, an endpoint — rather than
81
+ anything the retry loop can do.
82
+ """
83
+
84
+
85
+ def _credentials_help(
86
+ archive: str, name: str, s3: S3Options | None, exc: object
87
+ ) -> str:
88
+ """What to actually do about a failed archive read.
89
+
90
+ Long on purpose. This fires on a box that is behind, at the moment its
91
+ operator most needs to know whether the problem is a typo, a missing
92
+ role, or a genuinely unreadable bucket — and "AccessDenied" on its own
93
+ answers none of those.
94
+ """
95
+ where = (
96
+ f"endpoint {s3.endpoint}"
97
+ if s3 is not None and s3.endpoint
98
+ else "the AWS default endpoint"
99
+ )
100
+ region = f", region {s3.region}" if s3 is not None and s3.region else ""
101
+ keyed = (
102
+ "an explicit access key"
103
+ if s3 is not None and s3.access_key
104
+ else "the ambient credential chain (profile, instance metadata, SSO)"
105
+ )
106
+
107
+ return (
108
+ f"cannot read stream {name!r} from {archive} to catch up.\n"
109
+ f"\n"
110
+ f" tried: {where}{region}\n"
111
+ f" credentials: {keyed}\n"
112
+ f" underlying: {type(exc).__name__}: {str(exc)[:200]}\n"
113
+ f"\n"
114
+ f"This consumer has fallen further behind than the server will replay, "
115
+ f"so the missing rows can only come from the archive — and reading it "
116
+ f"needs credentials this process does not appear to have.\n"
117
+ f"\n"
118
+ f" * On AWS, the usual fix is an instance role or profile that can "
119
+ f"GET and LIST under {archive}.\n"
120
+ f" * Elsewhere, set AWS_ENDPOINT_URL, AWS_ACCESS_KEY_ID, "
121
+ f"AWS_SECRET_ACCESS_KEY and AWS_REGION, or pass "
122
+ f"streamcast.S3Options(...) as `s3=`.\n"
123
+ f" * `catch_up=False` turns this back into the plain NotReplayable "
124
+ f"refusal, if you would rather handle the gap yourself.\n"
125
+ f" * To skip the gap and accept the loss, reconnect with "
126
+ f"offset=streamcast.EARLIEST, or with no offset at all for live only."
127
+ )
128
+
129
+
130
+ class CatchUp:
131
+ """A bounded read of one stream's archive, and where to resume after it."""
132
+
133
+ __slots__ = ("_archive", "_name", "_reader", "_s3")
134
+
135
+ def __init__(self, archive: str, name: str, s3: S3Options | None) -> None:
136
+ self._archive = archive
137
+ self._name = name
138
+ self._s3 = s3
139
+ self._reader: RemoteReadHandle | None = None
140
+
141
+ async def open(self) -> int:
142
+ """Assemble the reader and return the offset after its last row.
143
+
144
+ In a thread: `litelink.snapshot` resolves a catalog and reads table
145
+ metadata over the network, which is seconds, and on the event loop
146
+ that is the consumer's whole process stopped.
147
+
148
+ The credential failure is caught HERE rather than at the first batch,
149
+ because this is the call that touches the bucket first and the caller
150
+ should learn it cannot read before it has been told it is recovering.
151
+ """
152
+ try:
153
+ self._reader = await asyncio.to_thread(
154
+ litelink.snapshot, self._name, archive=self._archive, s3=self._s3
155
+ )
156
+ return await asyncio.to_thread(self._reader.end_offset)
157
+
158
+ except Exception as exc:
159
+ await self.close()
160
+ raise CatchUpUnavailable(
161
+ _credentials_help(self._archive, self._name, self._s3, exc)
162
+ ) from exc
163
+
164
+ async def floor(self) -> int | None:
165
+ """The lowest offset the archive holds, or None if it will not say.
166
+
167
+ Read so that an archive which does not go back far enough fails at
168
+ `connect` rather than at the caller's first `recv` — the same reason
169
+ `open` is eager. None is not "holds nothing": it is the reader
170
+ declining to report an extent, and the check in `Catcher.stream`
171
+ covers that case from the rows themselves.
172
+ """
173
+ if self._reader is None: # pragma: no cover — `open` comes first
174
+ msg = "open() before floor()"
175
+ raise RuntimeError(msg)
176
+
177
+ extent = (await asyncio.to_thread(self._reader.coverage)).archive
178
+
179
+ return None if extent is None else extent[0]
180
+
181
+ def rows(self, start: int, stop: int) -> AsyncGenerator[tuple[int, dict], None]:
182
+ """`[start, stop)` from the archive, one batch in memory at a time.
183
+
184
+ The server's own batch reader, reused — so a caught-up row is built
185
+ exactly the way a replayed one is, from the same projection in the
186
+ same order.
187
+ """
188
+ if self._reader is None: # pragma: no cover — `open` comes first
189
+ msg = "open() before rows()"
190
+ raise RuntimeError(msg)
191
+
192
+ return _log.rows(self._reader, start, stop)
193
+
194
+ async def close(self) -> None:
195
+ reader, self._reader = self._reader, None
196
+ if reader is not None:
197
+ # A snapshot owns a scratch directory it removes on close, so
198
+ # leaking one leaks disk as well as a DuckDB connection.
199
+ await asyncio.to_thread(reader.close)
200
+
201
+
202
+ class Catcher:
203
+ """Read the archive, connect, and go round again if still too far behind.
204
+
205
+ The loop is the whole design. Each round reads whatever the archive holds
206
+ above where the last one stopped, then asks the server to take over from
207
+ there. A round that fails has not wasted its work: the rows it yielded are
208
+ already delivered, and the next round starts above them.
209
+ """
210
+
211
+ __slots__ = (
212
+ "_archive",
213
+ "_first",
214
+ "_frontier",
215
+ "_handshake",
216
+ "_name",
217
+ "_retries",
218
+ "_s3",
219
+ "connection",
220
+ "info",
221
+ "start",
222
+ )
223
+
224
+ def __init__(
225
+ self,
226
+ archive: str,
227
+ name: str,
228
+ s3: S3Options | None,
229
+ start: int,
230
+ retries: int,
231
+ handshake: Callable[[int], Awaitable[tuple[Any, Any]]],
232
+ ) -> None:
233
+ self._archive = archive
234
+ self._name = name
235
+ self._s3 = s3
236
+ self._retries = retries
237
+ self._handshake = handshake
238
+ self.start = start
239
+ self.connection: Any = None
240
+ self.info: Any = None
241
+ # The first round's reader, opened by `prepare` rather than inside
242
+ # the loop — see there for why.
243
+ self._first: CatchUp | None = None
244
+ self._frontier = 0
245
+
246
+ async def prepare(self) -> None:
247
+ """Open the first reader NOW, before any rows are asked for.
248
+
249
+ **So that an unreadable archive raises at `connect`.** The rows stream
250
+ lazily, which puts everything inside `stream` on the caller's first
251
+ `recv` — and a consumer told its subscription was open, then handed an
252
+ S3 credentials error minutes later from whatever line happened to read
253
+ next, is exactly the failure the eager greeting exists to prevent.
254
+ Observed doing precisely that before this existed.
255
+
256
+ It settles the first round's frontier too, so "the archive does not
257
+ reach far enough" also lands at `connect`.
258
+ """
259
+ self._first = CatchUp(self._archive, self._name, self._s3)
260
+ self._frontier = await self._first.open()
261
+ if self._frontier <= self.start:
262
+ await self._first.close()
263
+ self._first = None
264
+ raise _nothing_above(self._name, self._archive, self._frontier, self.start)
265
+
266
+ # And the other end of the range. An archive can end above the
267
+ # request and still not go back far enough to cover it, which is the
268
+ # case that used to be served silently from wherever the archive did
269
+ # start — 400 rows missing and a cursor advanced past them.
270
+ floor = await self._first.floor()
271
+ if floor is not None and floor > self.start:
272
+ await self._first.close()
273
+ self._first = None
274
+ raise _gap_below(self._name, self._archive, floor, self.start)
275
+
276
+ async def close(self) -> None:
277
+ """Release a reader `prepare` opened that `stream` never took.
278
+
279
+ `prepare` opens the first round's reader eagerly, so that a
280
+ credentials failure lands at `connect`. If the caller then closes the
281
+ subscription without ever calling `recv`, the generator below is never
282
+ STARTED — `aclose` on an unstarted generator runs no code, so the
283
+ `finally` that closes the reader never runs either, and a DuckDB
284
+ connection and the snapshot's scratch directory are left behind.
285
+
286
+ Idempotent, and a no-op in the ordinary case: `stream` clears `_first`
287
+ the moment it takes it, so only the never-read path has anything here.
288
+ """
289
+ first, self._first = self._first, None
290
+ if first is not None:
291
+ await first.close()
292
+
293
+ async def stream(self) -> AsyncGenerator[tuple[int, dict], None]:
294
+ """Yield the gap, and leave `connection` set when it returns.
295
+
296
+ Nothing is connected while rows are being yielded. That is the point.
297
+ """
298
+ refused: NotReplayable | None = None
299
+ # What the consumer actually asked for, kept because `self.start`
300
+ # advances as rows are delivered. The first row to come out of the
301
+ # archive is checked against THIS.
302
+ requested = self.start
303
+ checked = False
304
+ for _attempt in range(self._retries):
305
+ # Round one uses what `prepare` already opened, so the credential
306
+ # check and the first read are not two round trips.
307
+ reader = self._first or CatchUp(self._archive, self._name, self._s3)
308
+ frontier = self._frontier if self._first is not None else 0
309
+ self._first = None
310
+ try:
311
+ if frontier == 0:
312
+ frontier = await reader.open()
313
+
314
+ if frontier > self.start:
315
+ async for offset, row in reader.rows(self.start, frontier):
316
+ if not checked:
317
+ checked = True
318
+ if offset > requested:
319
+ # **The hole at the join, caught at the other
320
+ # end.** `prepare` rules out an archive that
321
+ # ENDS below the request; this rules out one
322
+ # that STARTS above it. Measured before this
323
+ # existed: a consumer asking for offset 100
324
+ # against an archive floored at 500 was
325
+ # handed 500 first and told nothing, losing
326
+ # 400 messages and advancing its cursor past
327
+ # them. `_stream._replay_from` pulls a row
328
+ # early for exactly this reason on the server
329
+ # side; the archive needed the same guard.
330
+ #
331
+ # `prepare` normally catches this first, from
332
+ # the reader's own extent. This is the
333
+ # backstop for a reader that will not report
334
+ # one, and for retention moving the floor up
335
+ # between `prepare` and the read.
336
+ raise _gap_below(
337
+ self._name, self._archive, offset, requested
338
+ )
339
+
340
+ yield offset, row
341
+ # Tracked per ROW, so a round that fails partway still
342
+ # leaves the next one starting where this one stopped.
343
+ self.start = offset + 1
344
+
345
+ finally:
346
+ await reader.close()
347
+
348
+ try:
349
+ self.connection, self.info = await self._handshake(self.start)
350
+ return
351
+
352
+ except NotReplayable as exc:
353
+ if exc.why not in RECOVERABLE:
354
+ raise
355
+
356
+ # Still behind: the server moved on while the gap was being
357
+ # read. The archive will have moved with it, so go again.
358
+ refused = exc
359
+
360
+ msg = (
361
+ f"{self._name!r} could not be caught up in {self._retries} rounds: "
362
+ f"after reading the archive at {self._archive} up to offset "
363
+ f"{self.start}, the server still will not replay from there "
364
+ f"({refused}). The stream is being published faster than its "
365
+ f"archive is synced — raise the server's `max_replay`, sync more "
366
+ f"often, or pass a larger `catch_up_retries`."
367
+ )
368
+ raise CatchUpUnavailable(msg)
369
+
370
+
371
+ def _nothing_above(
372
+ name: str, archive: str, frontier: int, start: int
373
+ ) -> CatchUpUnavailable:
374
+ """The archive does not reach the offset being asked for.
375
+
376
+ A range exists that the server has forgotten and the archive never
377
+ received. Reported with both numbers rather than half-served, because a
378
+ consumer that silently resumed above it would have lost data and been told
379
+ it recovered.
380
+ """
381
+ return CatchUpUnavailable(
382
+ f"{name!r} is behind the server's replay window and the archive at "
383
+ f"{archive} ends at offset {frontier}, which is not above the {start} "
384
+ f"being asked for. The rows between are gone from both — neither "
385
+ f"holds them: the server has forgotten them and the archive never "
386
+ f"received them. Reconnect with offset=streamcast.EARLIEST to take "
387
+ f"what is left and accept the loss."
388
+ )
389
+
390
+
391
+ def _gap_below(
392
+ name: str, archive: str, earliest: int, requested: int
393
+ ) -> CatchUpUnavailable:
394
+ """The archive does not go back as far as the offset being asked for.
395
+
396
+ The server refused because its own tier had already dropped the rows, and
397
+ the archive turns out not to hold them either — so they are gone. Raised
398
+ rather than served from wherever the archive does start, because a
399
+ consumer handed a stream that silently begins above where it asked has
400
+ lost data and been told it recovered.
401
+ """
402
+ return CatchUpUnavailable(
403
+ f"{name!r} asked to catch up from offset {requested}, but the archive "
404
+ f"at {archive} starts at {earliest} — the {earliest - requested} rows "
405
+ f"between are in neither the server nor the archive. They are gone. "
406
+ f"Reconnect with offset=streamcast.EARLIEST to take what is left and "
407
+ f"accept the loss, or with offset={earliest} to state that you know "
408
+ f"what is missing."
409
+ )
410
+
411
+
412
+ def from_refusal(exc: NotReplayable, configured: str | None) -> str | None:
413
+ """Where to read the gap, from the caller or from the refusal.
414
+
415
+ Explicit wins: a caller that named an archive meant that one, and it also
416
+ covers a server whose own is unreachable from here. Otherwise the refusal
417
+ may carry it — but only when it fitted, since `refusal` trims to 123 bytes
418
+ and the numbers are ordered ahead of it. `None` means ask the greeting.
419
+ """
420
+ if configured:
421
+ return configured
422
+
423
+ found = exc.fields.get("archive")
424
+
425
+ return found if isinstance(found, str) and found else None
426
+
427
+
428
+ def nowhere_to_read(name: str) -> CatchUpUnavailable:
429
+ """Neither the caller, the refusal, nor the greeting named an archive."""
430
+ return CatchUpUnavailable(
431
+ f"stream {name!r} is further behind than the server will replay, and "
432
+ f"there is no archive to read the gap from — the server has none "
433
+ f"configured. Either give the server an archive (litelink's "
434
+ f"`archive=`), raise its `max_replay`, or reconnect with "
435
+ f"offset=streamcast.EARLIEST to take what it still holds and accept "
436
+ f"the loss. `catch_up=False` turns this back into the plain refusal."
437
+ )
438
+
439
+
440
+ __all__ = [
441
+ "RECOVERABLE",
442
+ "CatchUp",
443
+ "CATCH_UP_RETRIES",
444
+ "CatchUpUnavailable",
445
+ "Catcher",
446
+ "from_refusal",
447
+ "nowhere_to_read",
448
+ ]