feat(replays): manifest + bounded chunk ranges for playback - #338
Conversation
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|
Opening a replay often failed with 503 "Database is temporarily unavailable". Nothing was unavailable: getReplayEvents selected Events for every chunk of a session with no LIMIT and buffered it into a 128 MB Worker. Ingest allows 1 GiB per session (p99 ~594 MB), so the abort matched the transient-upstream rule and was retried 3x before failing. Prod spans ran 428ms to 12.6s against a 15s cap. Playback now reads the way other replay products do: a cheap manifest of chunk positions and sizes, then payloads a bounded range at a time, seeded from a checkpoint. Both endpoints are v2-only; the v1 payload endpoint is deleted so the unbounded read cannot come back.
A fixed 16-chunk range assumed uniform chunk sizes. They are not: a flush is ~100 KB but a full DOM snapshot runs far larger, and real sessions carry 838 KB chunks — 16 of those is ~13 MB against an 8 MB response ceiling, so the request comes back 413 and that stretch of the recording cannot be loaded at all. Ranges are now planned from the manifest's byte sizes. Walking greedily from the first chunk keeps boundaries stable as a live session appends, which is what the atom cache keys on.
Two defects found reviewing the combined change. A live session's plan has a partial trailing range, so a range loaded as 40-45 becomes 40-55 once more chunks land. That is a different cache key, so it is fetched again — and chunks 40-45 were handed to addEvent a second time, replaying those mutations into the live engine. Commits now drop chunks already loaded, and anything outside the range being committed. Separately, limit=100 is inside the public 1-100 range but above the per-request chunk cap. The lookahead asked for 101 rows, got the SQL cap of 41, and read 41 <= 100 as "no more pages" — a short page reported as complete, silently dropping the rest of the recording. The page size is clamped to the same cap the SQL enforces.
54ec207 to
c1f2173
Compare
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
|
Found 1 test failure on Blacksmith runners: Failure
|

![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light.png)
Why
Opening a replay often failed with 503 "Database is temporarily unavailable". Nothing was unavailable, and retrying could only fail the same way.
getReplayEventsselectedEventsfor every chunk of a session with no LIMIT and buffered it into a 128 MB Worker. Ingest allows 1 GiB per session (p99 ~594 MB), so the abort matched the transient-upstream rule inexecution/errors.ts, became aWarehouseUpstreamError, and was retried 3× before failing. ProductiongetReplayEventsspans on Aug 3–4 ran 428ms → 12.6s against thelistprofile's 15s cap — the tail was walking into the wall.#337 moves where those bytes live; it explicitly does not change how many are read at once. That's this PR.
What changed
Playback reads the way other replay products do (PostHog returns snapshot sources then fetches blobs; Sentry paginates segments): a cheap manifest of chunk positions and sizes, then payloads a bounded range at a time, seeded from a checkpoint.
GET /v2/session_replays/:id/manifest— every chunk's position and size, no payloads. Pure index read; nothing to hydrate whatever the storage backend.GET /v2/session_replays/:id/events?from_chunk_seq=&to_chunk_seq=— bounded, cursor-paginated within the range, pushed into SQL.Not streaming. On the managed Tinybird backend
boundedResponseFetchreassembles the whole body before the SDK parses it, soHttpServerResponse.streamwould stream a buffer the Worker already materialized — same OOM, more code, and it would leave the typed client path.The v1 payload endpoint is deleted, not kept in sync. A second surface is a second way to reintroduce the unbounded read. Every other v1 replay endpoint is untouched. v1 no longer needs
ReplayBlobStoreat all, so #337's v1 hydration wiring comes back out.The seam with #337
Worth reviewing closely, because combining these naively silently disables the guard this PR exists for.
compiledQueryBounded'sresponseLimitscaps what the warehouse returns. Once payloads live in R2 that response is only an index —eventsis""— so the ceiling stops measuring anything that matters, and the memory cost moves toblobs.hydrate(), which is unbounded.Both ceilings are now in place, because there are two places a payload can come from:
responseLimits— still the only guard for pre-cutover rows, which carryeventsinline.assertRangeFitsBudget— sumsByteSizefrom the index and refuses before hydrating.ByteSizeis the uncompressed size feat(replays): store rrweb chunk payloads in R2, not ClickHouse #337 deliberately preserved, so an over-budget range is rejected without fetching a single object.Over-budget is a typed 413
range_too_large, exempt from the v2 redaction: it carries no database diagnostics, only the range asked for, and it is the one error here where telling the caller what to do is the whole value.Caught by end-to-end testing
Two things that unit tests would not have found, both fixed here:
A
FirstEventMscolumn was added for exact seek anchoring, then reverted. The deployed cluster doesn't have it, so every read returnedwarehouse_schema_drift(502) — and the insert mapping named a column the table lacks, which would have broken replay ingestion. It also tripped the local-CLI append-only schema gate. Chunk position now comes fromTimestamp; it trails by upload latency, which is well inside one chunk, and the exact offset comes from the loaded rrweb events. Net: this PR changes no schema.Fixed-size ranges 413 on real data. A 16-chunk range assumed uniform chunks. Production sessions carry 838 KB chunks — 16 of those is ~13 MB against an 8 MB ceiling. Ranges are now planned from the manifest's byte sizes; walking greedily from the first chunk keeps boundaries stable as a live session appends, which is what the atom cache keys on. Verified live: the range came back
0–8, not0–15.Also fixed while verifying: the engine-mount effect keyed on the manifest total, so every manifest refetch rebuilt the Replayer — on a live session that resets the playhead every few seconds and reads as "playback frozen".
Compatibility
No schema change and no deploy ordering. The read path uses only columns every existing recording already has. Covered explicitly: checkpoint-less sessions (the SDK's over-cap guard drops the opening snapshot), single-chunk sessions, sessions whose
chunk_seqdoesn't start at 0, and JSON-quoted UInt64s from backends that refuse the unquote setting.Testing
958query-engine ·448domain ·106browser-session ·103web replays ·325api ·152ingest (Rust) — all pass. Typecheck clean across five packages;clickhouse:schema:checkpasses in full, including the local-schema history check (still v1).Verified live against the real stack (api + web, production-shaped data): manifest
200→ byte-planned range200→ 47 KB of replayed DOM, scrubber at the manifest-derived duration, seeded from the first checkpoint rather than chunk 0.One limit: wall-clock playback could not be driven in the verification browser — the pane renders hidden (
document.hidden === true, a direct probe fired 0 rAF frames in 2s), and both the clock loop and rrweb's timer are rAF-driven. Transport and rendering are proven live; playback and seek behaviour are pinned inreplay-progressive-load.test.tsas a simulated 100-chunk playthrough (each range fetched once, tail still unfetched mid-session, scrub-back hits cache, a seek past the loaded span rebuilds from the preceding checkpoint). Worth one manual click of play before merge.Follow-ups, not here
deriveMetasees only loaded regions, so timeline markers fill in as you play. The right fix is sourcing them fromsessionTranscriptQuery, which is already bounded, paginated, and prefetched by the route.TRANSIENT_RETRY_SCHEDULEretries a query that has already burned most of the request budget — a guard there would cap tail latency across every route.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.