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 +128 -0
- streamcast/__main__.py +42 -0
- streamcast/_catchup.py +448 -0
- streamcast/_client.py +772 -0
- streamcast/_cursor.py +153 -0
- streamcast/_errors.py +202 -0
- streamcast/_log.py +217 -0
- streamcast/_maintain.py +274 -0
- streamcast/_protocol.py +417 -0
- streamcast/_remote.py +198 -0
- streamcast/_replicate.py +253 -0
- streamcast/_schema.py +262 -0
- streamcast/_server.py +318 -0
- streamcast/_stream.py +686 -0
- streamcast/_subscriber.py +168 -0
- streamcast/py.typed +0 -0
- streamcast-0.1.0.dist-info/METADATA +399 -0
- streamcast-0.1.0.dist-info/RECORD +21 -0
- streamcast-0.1.0.dist-info/WHEEL +4 -0
- streamcast-0.1.0.dist-info/licenses/LICENSE +202 -0
- streamcast-0.1.0.dist-info/licenses/NOTICE +4 -0
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
|
+
]
|