Skip to content

Commit 11f2f54

Browse files
fix(backend): terminalize oversized conversation doc rejections and cap occasion size (#23341)
- Add proactive byte-size (OCCASION_MAX_DOCUMENT_BYTES = 786_432) and segment-count (OCCASION_MAX_TRANSCRIPT_SEGMENTS = 1_500) rollover caps to occasion conversations before Firestore rejects writes. - Terminalize permanent Firestore 1 MiB document size rejections across durable conversation-finalization jobs, enrichment consumer callbacks, summary delivery, and v2 sync jobs. - Fall back to shrinking status-only write (status="completed") in fit_document_limit when dead_letter_conversation_updates itself exceeds the 1 MiB ceiling. Failure-Class: FC-permanent-write-rejection-retried-forever Fixes #23341
1 parent 577af33 commit 11f2f54

13 files changed

Lines changed: 415 additions & 32 deletions

‎backend/config/occasion_conversations.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,18 @@
1919

2020
from __future__ import annotations
2121

22+
from collections.abc import Mapping, Sequence
2223
import os
24+
from typing import Any
2325

2426
OCCASION_CONVERSATIONS_ENABLED_ENV = 'OCCASION_CONVERSATIONS_ENABLED'
2527
OCCASION_GAP_SECONDS = 15 * 60
2628
OCCASION_MAX_SPAN_SECONDS = 3 * 60 * 60
29+
# Keep 256 KiB of headroom below Firestore's 1 MiB (1,048,576-byte) ceiling so
30+
# finalization bindings, summaries, action items, app results, and outbox
31+
# metadata can still commit without hitting InvalidArgument.
32+
OCCASION_MAX_DOCUMENT_BYTES = 1_048_576 - 262_144
33+
OCCASION_MAX_TRANSCRIPT_SEGMENTS = 1_500
2734

2835
_OFF = frozenset({'0', 'false', 'off', 'no'})
2936
# Sources whose clients do not show episodes; their conversations keep today's boundaries.
@@ -45,6 +52,23 @@ def occasion_timeout(timeout: int) -> int:
4552
return max(int(timeout), OCCASION_GAP_SECONDS)
4653

4754

55+
def occasion_size_exceeded(
56+
conversation: Mapping[str, Any],
57+
document_path: str | None = None,
58+
) -> bool:
59+
"""Whether a live conversation has reached the segment-count or byte-size rollover cap."""
60+
segments = conversation.get('transcript_segments')
61+
if (
62+
isinstance(segments, Sequence)
63+
and not isinstance(segments, (str, bytes, bytearray))
64+
and len(segments) >= OCCASION_MAX_TRANSCRIPT_SEGMENTS
65+
):
66+
return True
67+
from database.conversation_terminal_title import estimate_firestore_document_bytes
68+
69+
return estimate_firestore_document_bytes(conversation, document_path) >= OCCASION_MAX_DOCUMENT_BYTES
70+
71+
4872
# Explicit rollout bindings for inventory and operators. The generic parser in
4973
# refactor_modes owns every read, including the two suffixes derived from a mode.
5074
EPISODE_BOUNDARIES_MODE_ENV = 'EPISODE_BOUNDARIES_MODE'

‎backend/database/conversation_quiet_checks.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515

1616
from database._client import get_firestore_client
1717
from database import conversation_finalization_jobs as jobs_db
18+
from database.conversation_terminal_title import (
19+
FIRESTORE_MAX_DOCUMENT_BYTES,
20+
TERMINAL_SIZE_HEADROOM_BYTES,
21+
estimate_firestore_document_bytes,
22+
)
1823
from models.conversation_continuity import continuation_timeout
1924
from config.refactor_modes import serving_for
2025
from utils.conversations.lifecycle_clocks import speech_clock
@@ -50,6 +55,17 @@ def quiet_deadline(conversation: dict[str, Any], timeout: Any = 120, *, uid: str
5055
return finish + timedelta(seconds=continuation_timeout(conversation, quiet_timeout(timeout)))
5156

5257

58+
def _update_row_if_fits(transaction: Any, row_ref: Any, row: dict[str, Any], updates: dict[str, Any]) -> None:
59+
# Skip arming stamps when the conversation is at the 1 MiB ceiling; quiet_finalization.run() retires the check.
60+
path = getattr(row_ref, 'path', None)
61+
doc_path = path if isinstance(path, str) else None
62+
if (
63+
estimate_firestore_document_bytes({**row, **updates}, doc_path) + TERMINAL_SIZE_HEADROOM_BYTES
64+
<= FIRESTORE_MAX_DOCUMENT_BYTES
65+
):
66+
transaction.update(row_ref, updates)
67+
68+
5369
def arm_quiet_check(
5470
uid: str, conversation_id: str, timeout: float, *, requires_byok: bool = False, firestore_client: Any = None
5571
) -> dict[str, Any] | None:
@@ -84,8 +100,10 @@ def arm(transaction: Any) -> dict[str, Any] | None:
84100
job.get('quiet_timeout_seconds') != quiet_timeout(timeout)
85101
or row.get('listen_finalization_requires_byok') != requires_keys
86102
):
87-
transaction.update(
103+
_update_row_if_fits(
104+
transaction,
88105
row_ref,
106+
row,
89107
{
90108
'listen_quiet_timeout_seconds': quiet_timeout(timeout),
91109
'listen_finalization_requires_byok': requires_keys,
@@ -106,8 +124,10 @@ def arm(transaction: Any) -> dict[str, Any] | None:
106124
'created_at': job.get('created_at') or now,
107125
'updated_at': now,
108126
}
109-
transaction.update(
127+
_update_row_if_fits(
128+
transaction,
110129
row_ref,
130+
row,
111131
{
112132
'listen_quiet_timeout_seconds': quiet_timeout(timeout),
113133
'listen_finalization_requires_byok': requires_keys,

‎backend/database/conversation_terminal_title.py‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,29 @@ def fit_document_limit(
169169
``base_update`` is the terminal's own status write, which the caller has
170170
always committed. ``extras`` (title, retry marker) are optional growth: an
171171
oversized update aborts the whole transaction and would strand the row on
172-
``processing``, so they are dropped rather than risk that.
172+
``processing``, so they are dropped rather than risk that. When the row is
173+
already so close to 1 MiB that optional fields in ``base_update`` would
174+
also exceed the ceiling, fall back to the status-only write.
173175
"""
174-
combined = {**base_update, **extras}
175-
if not extras:
176-
return combined
177-
after = {**conversation, **combined}
178176
path = getattr(conversation_ref, 'path', None)
179-
estimated = estimate_firestore_document_bytes(after, path if isinstance(path, str) else None)
180-
if estimated + TERMINAL_SIZE_HEADROOM_BYTES > FIRESTORE_MAX_DOCUMENT_BYTES:
181-
return dict(base_update)
182-
return combined
177+
doc_path = path if isinstance(path, str) else None
178+
combined = {**base_update, **extras}
179+
if extras:
180+
after = {**conversation, **combined}
181+
if (
182+
estimate_firestore_document_bytes(after, doc_path) + TERMINAL_SIZE_HEADROOM_BYTES
183+
<= FIRESTORE_MAX_DOCUMENT_BYTES
184+
):
185+
return combined
186+
base = dict(base_update)
187+
if len(base) > 1 and 'status' in base:
188+
after_base = {**conversation, **base}
189+
if (
190+
estimate_firestore_document_bytes(after_base, doc_path) + TERMINAL_SIZE_HEADROOM_BYTES
191+
> FIRESTORE_MAX_DOCUMENT_BYTES
192+
):
193+
return {'status': base['status']}
194+
return base
183195

184196

185197
def _has_described_photo(conversation: Mapping[str, Any], conversation_ref: Any, transaction: Any) -> bool:

‎backend/routers/conversation_finalization.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
from datetime import timedelta
67
import logging
78
import os
89
from typing import Any
@@ -22,6 +23,7 @@
2223
from utils.account_cutover.access import should_skip_background_account_mutation
2324
from utils.conversations import lifecycle as lifecycle_service
2425
from utils.conversations import quiet_finalization
26+
from utils.conversations.finalization_failure import classify_finalization_failure
2527
from utils.conversations.processing_trigger import trigger_for_finalization_job
2628
from utils.conversations.recovery import RecoveryStructureUnavailableError, recovery_minimum_terminal_enabled
2729
from utils.conversations.finalizer import (
@@ -105,6 +107,57 @@ async def _retry_or_dead_letter(
105107
return False
106108

107109

110+
async def _terminalize_oversized_finalization_job(
111+
job: dict[str, Any],
112+
job_id: str,
113+
dispatch_generation: int,
114+
claimed_lease_epoch: int,
115+
this_attempt: int,
116+
) -> JSONResponse:
117+
"""Close a durable finalization job whose conversation hit Firestore's 1 MiB ceiling."""
118+
if job.get('fanout_status') == 'completed':
119+
completed = await run_blocking(
120+
db_executor,
121+
jobs_db.mark_finalization_completed,
122+
job_id,
123+
dispatch_generation,
124+
claimed_lease_epoch,
125+
)
126+
if not completed:
127+
return JSONResponse(status_code=409, content={'status': 'completion_conflict'})
128+
return JSONResponse(status_code=200, content={'status': 'done'})
129+
try:
130+
await run_blocking(
131+
db_executor,
132+
lifecycle_service.close_oversized_in_progress_conversation,
133+
job['uid'],
134+
job['conversation_id'],
135+
quiet_for=timedelta(0),
136+
)
137+
except Exception:
138+
logger.warning(
139+
'listen finalization oversized in_progress close skipped job_hash=%s',
140+
finalization_diagnostic_id(job_id),
141+
)
142+
terminal = await run_blocking(
143+
db_executor,
144+
final_attempt_failed,
145+
job_id,
146+
dispatch_generation,
147+
claimed_lease_epoch,
148+
this_attempt,
149+
failure_code='document_size_limit',
150+
)
151+
if not terminal:
152+
return JSONResponse(status_code=409, content={'status': 'completion_conflict'})
153+
logger.warning(
154+
'listen finalization document size limit terminal job_hash=%s dispatch_generation=%s',
155+
finalization_diagnostic_id(job_id),
156+
dispatch_generation,
157+
)
158+
return JSONResponse(status_code=200, content={'status': 'dead_letter'})
159+
160+
108161
@router.post('/v1/conversation-finalization-jobs/run', include_in_schema=False)
109162
async def run_listen_finalization_job(
110163
request: Request,
@@ -273,6 +326,17 @@ async def run_listen_finalization_job(
273326
>= get_listen_finalization_tasks_max_attempts_for_worker() - 1,
274327
)
275328
except ConversationFinalizationError as error:
329+
if (
330+
error.__cause__ is not None
331+
and classify_finalization_failure(error.__cause__, job['conversation_id']).conversation_at_size_limit
332+
):
333+
return await _terminalize_oversized_finalization_job(
334+
job,
335+
job_id,
336+
dispatch_generation,
337+
claimed_lease_epoch,
338+
max(task_retry_count, failed_attempts) + 1,
339+
)
276340
if recovery_minimum_terminal_enabled() and isinstance(error.__cause__, RecoveryStructureUnavailableError):
277341
# A completed fanout wins even if its worker failed before
278342
# acknowledging the job. The lease-fenced transaction also
@@ -352,6 +416,25 @@ async def run_listen_finalization_job(
352416
logger.warning('listen finalization handler cancelled job=%s; preserving run lock until TTL', job_id)
353417
raise
354418
except Exception as error:
419+
if (
420+
claimed_lease_epoch is not None
421+
and job is not None
422+
and isinstance(job.get('conversation_id'), str)
423+
and classify_finalization_failure(error, job['conversation_id']).conversation_at_size_limit
424+
):
425+
try:
426+
return await _terminalize_oversized_finalization_job(
427+
job,
428+
job_id,
429+
dispatch_generation,
430+
claimed_lease_epoch,
431+
max(task_retry_count, failed_attempts) + 1,
432+
)
433+
except Exception:
434+
logger.error(
435+
'listen finalization oversized terminal update failed job=%s',
436+
job_id,
437+
)
355438
logger.warning(
356439
'listen finalization worker failed stage=handler exception_type=%s job_hash=%s dispatch_generation=%s',
357440
type(error).__name__,

‎backend/routers/listen/conversations.py‎

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
should_attach_to_existing_in_progress,
4747
)
4848
from utils.transcribe_store import calendar_db, conversations_db, redis_db
49-
from config.occasion_conversations import OCCASION_MAX_SPAN_SECONDS
49+
from config.occasion_conversations import OCCASION_MAX_SPAN_SECONDS, occasion_size_exceeded
5050
from utils.conversations.lifecycle_clocks import speech_clock
5151
from utils.episodes import boundaries, boundary_runtime
5252
from routers.listen.occasion import occasion_attach_target
@@ -161,12 +161,16 @@ async def _resume_continuation(self, pointer: dict[str, str]) -> bool:
161161
)
162162
if existing:
163163
existing = await self._continuity_row(existing)
164-
if not existing or not resumable_continuation(
165-
existing,
166-
source=normalize_listen_source(self.host.request.source),
167-
device_id=self.host.client_device_context.client_device_id,
168-
now=self.clock(),
169-
timeout=self.host.conversation_creation_timeout,
164+
if (
165+
not existing
166+
or self._conversation_size_exceeded(existing)
167+
or not resumable_continuation(
168+
existing,
169+
source=normalize_listen_source(self.host.request.source),
170+
device_id=self.host.client_device_context.client_device_id,
171+
now=self.clock(),
172+
timeout=self.host.conversation_creation_timeout,
173+
)
170174
):
171175
return False
172176
self.host.recording_session_id = pointer['recording_session_id']
@@ -533,14 +537,18 @@ async def create_new_in_progress_conversation(self, *, rollover: bool = False) -
533537
in_progress_status=ConversationStatus.in_progress,
534538
)
535539
if action == RecordingSessionReconnectAction.resume_current and not existing.get('deleted'):
536-
if not self.host.is_multi_channel and not resumable_continuation(
537-
existing,
538-
source=normalize_listen_source(request.source),
539-
device_id=self.host.client_device_context.client_device_id,
540-
now=self.clock(),
541-
timeout=self.host.conversation_creation_timeout,
540+
size_exceeded = self._conversation_size_exceeded(existing)
541+
if not self.host.is_multi_channel and (
542+
size_exceeded
543+
or not resumable_continuation(
544+
existing,
545+
source=normalize_listen_source(request.source),
546+
device_id=self.host.client_device_context.client_device_id,
547+
now=self.clock(),
548+
timeout=self.host.conversation_creation_timeout,
549+
)
542550
):
543-
if (
551+
if size_exceeded or (
544552
existing.get('source') == normalize_listen_source(request.source)
545553
and existing.get('client_device_id') == self.host.client_device_context.client_device_id
546554
and not any(
@@ -760,16 +768,20 @@ async def prepare(self) -> Optional[str]:
760768
existing = await self._continuity_row(existing)
761769
finished_at = speech_clock(self.host.request.uid, existing) or self.clock()
762770
seconds = (self.clock() - finished_at).total_seconds()
771+
size_exceeded = self._conversation_size_exceeded(existing)
763772
if (
764-
decide_existing_conversation_action(
773+
size_exceeded
774+
or decide_existing_conversation_action(
765775
seconds_since_last_segment=seconds,
766776
conversation_creation_timeout=continuation_timeout(existing, self.host.conversation_creation_timeout),
767777
)
768778
== ConversationLifecycleAction.process_and_create_new
769779
):
770780
# This runs before STT initialization: an outage cannot indefinitely
771781
# defer empty-generation cleanup. The transaction still lets content win.
772-
if not (existing.get('transcript_segments') or existing.get('photos') or existing.get('has_content')):
782+
if size_exceeded or not (
783+
existing.get('transcript_segments') or existing.get('photos') or existing.get('has_content')
784+
):
773785
await self._finalize_isolated(self.process_conversation, existing['id'], stage='prepare_rollover')
774786
await self.create_new_in_progress_conversation()
775787
return existing['id']
@@ -922,9 +934,17 @@ async def recover_stale_in_progress(self) -> None:
922934
continue
923935
await self._finalize_isolated(self.process_conversation, conversation['id'], stage='stale_in_progress')
924936

937+
def _conversation_size_exceeded(self, conversation: dict[str, Any]) -> bool:
938+
cid = conversation.get('id') or getattr(getattr(self.host, 'state', None), 'current_conversation_id', None)
939+
uid = getattr(getattr(self.host, 'request', None), 'uid', None)
940+
doc_path = f'users/{uid}/conversations/{cid}' if uid and cid else None
941+
return occasion_size_exceeded(conversation, document_path=doc_path)
942+
925943
def _occasion_span_exceeded(self, conversation: dict[str, Any]) -> bool:
926944
if not getattr(self.host, 'occasion_mode', False):
927945
return False
946+
if self._conversation_size_exceeded(conversation):
947+
return True
928948
started = persisted_started_seconds(conversation.get('started_at'))
929949
return started is not None and self.clock().timestamp() - started >= OCCASION_MAX_SPAN_SECONDS
930950

@@ -973,7 +993,7 @@ async def lifecycle_loop(self) -> None:
973993
if (
974994
boundary_action != 'hold'
975995
and action == ConversationLifecycleAction.continue_current
976-
and self._occasion_span_exceeded(conversation)
996+
and (self._occasion_span_exceeded(conversation) or self._conversation_size_exceeded(conversation))
977997
):
978998
# One occasion is capped like a smart-merged chain: all-day capture still rolls over.
979999
logger.info('event=occasion_span_rollover conversation=%s', conversation_id)

0 commit comments

Comments
 (0)