Skip to content

fix(backend): bound episode receipts out of the episode doc and cache the live-poll recap - #23544

Merged
Git-on-my-level merged 4 commits into
mainfrom
lane/episode-ids-cap
Oct 24, 2026
Merged

Git-on-my-level merged 4 commits into
mainfrom
lane/episode-ids-cap

Conversation

@Git-on-my-level

Copy link
Copy Markdown
Member

Summary

Follow-up to #23506 (b72ddce).

  • Bound unplaced-segment receipts out of the episode document. fix(backend): preserve episode speech liveness and closed recaps #23506 stored applied_segment_ids in 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 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.
  • The never-processed-twice guarantee is kept exactly. Live moments dedupe against the base document alone (their batches are recent: the tracker buffers at most 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.
  • Trim the live-poll reads. GET /v1/episodes/live gained two Firestore reads per poll for the additive last_closed_episode recap. 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.
  • Pin the wire contract. The route's response_model_exclude_unset could silently drop nested model fields the generated clients rely on; a test now asserts every Episode/EpisodeMoment/EpisodeProvisionalItem field 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

… 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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T23:01:14.900930Z 95e5613 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread backend/database/episode_receipts.py Outdated
Comment on lines +99 to +102
while len(merged) > SHARD_SIZE:
archives.append((shards, merged[:SHARD_SIZE]))
shards += 1
merged = merged[SHARD_SIZE:]

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 Badge 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 👍 / 👎.

Comment on lines +656 to +658
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

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 Badge 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 👍 / 👎.

Git-on-my-level and others added 2 commits October 25, 2026 04:22
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +109 to +110
spill = len(merged) - SHARD_SIZE
archives.append((shards, merged[:spill]))

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 Badge 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>
@Git-on-my-level
Git-on-my-level merged commit 3ded241 into main Oct 24, 2026
58 checks passed
@Git-on-my-level
Git-on-my-level deleted the lane/episode-ids-cap branch October 24, 2026 23:08
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