Repository navigation
fix(backend): bound episode receipts out of the episode doc and cache the live-poll recap - #23544
Conversation
… the live-poll recap Follow-up to #23506 (b72ddce): consumed unplaced-segment ids lived in the episode Firestore document, one id per consumed segment with no limit, so a very long unplaced conversation grew the document toward the 1 MiB limit; and GET /v1/episodes/live gained two Firestore reads per poll for the additive last-closed recap. Receipts are per-conversation state (a conversation spans episode boundaries and its finalization tail replays the whole stored transcript), so they now live in users/{uid}/episode_segment_receipts/{conversation_id}: the base document holds the most recent 2,000 ids, older ids roll into immutable s/{index} archive documents, and episode documents never carry them. The receipt write joins the episode compare-and-swap transaction, so a receipt exists exactly when the state that consumed the segment was stored. Live moments dedupe against the base document alone; the finalization tail reads every archive; a segment is never processed twice, including a 50k-segment whole-transcript tail (covered by a document-size test). Episodes written before the receipts collection kept ids in an applied_segment_ids field: the first post-change write adopts that list into the receipts and retires the in-document field, so a tail landing across the deploy still dedupes. The live poll serves the recap from a per-process cache for LAST_CLOSED_CACHE_SECONDS (30) while a live episode is on screen, and reads it fresh when no live episode is — the recovery-navigation answer the recap exists for. The response is unchanged, and a wire test now pins that response_model_exclude_unset never drops a field the generated clients require. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1b37f5facd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| while len(merged) > SHARD_SIZE: | ||
| archives.append((shards, merged[:SHARD_SIZE])) | ||
| shards += 1 | ||
| merged = merged[SHARD_SIZE:] |
There was a problem hiding this comment.
Retain recent receipts rather than lexicographically largest IDs
Once a conversation exceeds 2,000 consumed unplaced segments, sorting IDs and archiving the first 2,000 does not preserve the recent window that live deduplication depends on. Segment IDs are UUID4 values (models/transcript_segment.py), so newly consumed IDs can immediately enter an archive; with 2,001 IDs, the base also shrinks to just one ID. A replay or repaired placement of those recent segments is then fed into the model again because _record_moment_once reads archives only for finalize. Preserve consumption order and a sufficiently sized recent window when rolling receipts into archives.
Useful? React with 👍 / 👎.
| adopt = set() if receipts_exist else consumed | ||
| receipt_ids = selected_ids | adopt | ||
| segment_receipts = {moment.conversation_id: sorted(receipt_ids)} if receipt_ids else None |
There was a problem hiding this comment.
Preserve legacy receipts when replacement episode notes fail
For a pre-upgrade episode with applied_segment_ids but no receipt document, an idle/hard-cap boundary followed by a model failure loses the conversation's deduplication history. Adoption now happens only after the model succeeds, while the failure branch opens an empty replacement episode and repoints the conversation link without carrying the legacy IDs. Subsequent retries find neither receipts nor legacy IDs on the new owner, and the finalization tail reprocesses speech already consumed by the old episode. Persist the already-consumed legacy IDs when opening that replacement even if its new notes fail, without recording the failed moment's IDs.
Useful? React with 👍 / 👎.
plan_write sorted receipt ids alphabetically and archived the lowest-sorting shard, so past 2,000 receipts the base document kept the alphabetically highest UUIDs instead of the most recently consumed ids. Live moments dedupe against the base alone while the tracker re-presents its buffered segments and repairs place them later, so a just-consumed segment whose id sorted low was fed to the model twice. Receipt ids are now ordered oldest-first by insertion: stored order is preserved, new ids append, only the oldest overflow spills into the next immutable archive, and the base always keeps the most recent SHARD_SIZE window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 743de720bd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| spill = len(merged) - SHARD_SIZE | ||
| archives.append((shards, merged[:spill])) |
There was a problem hiding this comment.
Cap each archive chunk at SHARD_SIZE
When one write adds more than 4,000 IDs, spill contains the entire overflow rather than at most SHARD_SIZE, so the loop creates one oversized archive and immediately exits. This is reachable through _on_finalized, which passes the whole stored transcript to record_moment: 50,000 previously unconsumed unplaced UUIDs produce a single 48,000-ID archive, exceeding Firestore's 1 MiB document limit and rejecting the episode transaction. The existing 50,000-segment test misses this because it consumes the IDs in 2,000-item batches before finalization. Limit each spill to SHARD_SIZE and cover a large single-write batch.
Useful? React with 👍 / 👎.
Keep both sides of the two conflicts: the live-poll recap cache block plus main's 'sync_update' reconcile trigger in service.py, and both fast-unit duration allowlist additions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Follow-up to #23506 (b72ddce).
applied_segment_idsin the episode Firestore document — one id (~40 bytes) per consumed unplaced segment, with no limit — so a very long unplaced conversation grew the document toward the 1 MiB ceiling, where every later write becomes a rejection no retry of the same write can satisfy. The receipts are per-conversation state (a conversation spans episode boundaries and its finalization tail replays the whole stored transcript), so they now live inusers/{uid}/episode_segment_receipts/{conversation_id}: the base document holds the most recent 2,000 ids, older ids roll into immutables/{index}archive documents, and episode documents never carry them. The receipt write joins the episode compare-and-swap transaction, so a receipt exists exactly when the state that consumed the segment was stored.MAX_BUFFERED_SEGMENTS); the finalization tail — the only whole-transcript replay — reads every archive. A 50,000-segment all-unplaced conversation is covered by a test asserting every document stays small and the full-transcript tail still dedupes with zero extra model calls. Episodes written before this change kept ids in the episode document: the first post-change write adopts that list into the receipts and retires the in-document field, so a tail landing across the deploy still dedupes.GET /v1/episodes/livegained two Firestore reads per poll for the additivelast_closed_episoderecap. The recap changes only when an episode closes, so while a live episode is on screen it is served from a per-process cache (LAST_CLOSED_CACHE_SECONDS, 30s); when no live episode is on screen — the recovery-navigation answer the recap exists for — it is read fresh. The response shape is unchanged.response_model_exclude_unsetcould silently drop nested model fields the generated clients rely on; a test now asserts everyEpisode/EpisodeMoment/EpisodeProvisionalItemfield is always sent, with the additive recap (absent when there is none) the only intentional omission.Failure-Class: FC-permanent-write-rejection-retried-forever
🤖 Generated with Claude Code