Repository navigation
harden(pusher): persist playback projection rebuild debt #24725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
907f3cd
3ba79a7
2c461b9
64acd4f
0514ef8
f234e73
ad1ec6e
95a9ea4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| """Runtime kill switch for optional projection-debt handoff and maintenance.""" | ||
|
|
||
| import os | ||
|
|
||
|
|
||
| def enabled() -> bool: | ||
| return os.getenv('PUSHER_PROJECTION_DEBT_DURABLE_ENABLED', '1').strip().lower() not in {'0', 'false', 'off'} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """Content-free playback repair debt; no seal, producer-token or object authority.""" | ||
|
|
||
| import hashlib | ||
| import json | ||
| from datetime import datetime, timezone | ||
|
|
||
| from google.cloud import firestore | ||
|
|
||
| from database._client import get_firestore_client, run_transactional | ||
| from database.batch_pipeline_privacy import BatchPrivacyBlocked, BatchPrivacyReason, assert_batch_write_allowed | ||
|
|
||
| COLLECTION = 'record_projection_rebuild_jobs' | ||
| KIND = 'record_projection_rebuild_due' | ||
| MAX_MARKERS_PER_BATCH = 256 | ||
|
|
||
|
|
||
| def marker_id(uid, cid): | ||
| for value in (uid, cid): | ||
| if not isinstance(value, str) or not value or '/' in value or len(value.encode()) > 256: | ||
| raise ValueError('bounded projection owner required') | ||
| return hashlib.sha256(json.dumps([uid, cid], separators=(',', ':')).encode()).hexdigest() | ||
|
|
||
|
|
||
| def mark_due(uid, cid, *, firestore_client=None): | ||
| client = firestore_client if firestore_client is not None else get_firestore_client() | ||
| client.collection(COLLECTION).document(marker_id(uid, cid)).set( | ||
| {'kind': KIND, 'owner_id': uid, 'conversation_id': cid, 'requested_at': datetime.now(timezone.utc)}, | ||
| timeout=5, | ||
| retry=None, | ||
| ) | ||
|
|
||
|
|
||
| def mark_many(uid, conversation_ids, *, firestore_client=None): | ||
| """One bounded cleanup RPC, rather than one timeout per socket obligation.""" | ||
| owners = set(conversation_ids) | ||
| if not 0 < len(owners) <= MAX_MARKERS_PER_BATCH: | ||
| raise ValueError('bounded projection marker batch required') | ||
| client = firestore_client if firestore_client is not None else get_firestore_client() | ||
| batch = client.batch() | ||
| requested_at = datetime.now(timezone.utc) | ||
| for cid in owners: | ||
| ref = client.collection(COLLECTION).document(marker_id(uid, cid)) | ||
| batch.set(ref, {'kind': KIND, 'owner_id': uid, 'conversation_id': cid, 'requested_at': requested_at}) | ||
| batch.commit(timeout=1, retry=None) | ||
|
|
||
|
|
||
| def apply_projection(uid, cid, payload, *, expected_audio_files, firestore_client=None): | ||
| """Cold repair only: deletion intent and projection CAS share the commit.""" | ||
| from database.conversations import AudioFilesChanged, audio_files_revision, retain_aliases | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The import inside AGENTS.md reference: backend/AGENTS.md:L166-L168 Useful? React with 👍 / 👎. |
||
|
|
||
| client = firestore_client if firestore_client is not None else get_firestore_client() | ||
| ref = client.collection('users').document(uid).collection('conversations').document(cid) | ||
|
|
||
| @firestore.transactional | ||
| def commit(tx): | ||
| snapshot = ref.get(transaction=tx) | ||
| if not snapshot.exists: | ||
| return False | ||
| row = snapshot.to_dict() or {} | ||
| if row.get('deleted') or row.get('discarded') or row.get('private_cloud_sync_enabled') is False: | ||
| return False | ||
| try: | ||
| assert_batch_write_allowed(client, tx, uid, conversation_ids=(cid,)) | ||
| except BatchPrivacyBlocked as blocked: | ||
| if blocked.reason in { | ||
| BatchPrivacyReason.ACCOUNT_DELETED, | ||
| BatchPrivacyReason.ACCOUNT_MISSING, | ||
| BatchPrivacyReason.CONVERSATION_DELETED, | ||
| }: | ||
| return False | ||
| return None # Migration, generation holds and unknown refusals retain debt without a write. | ||
| if audio_files_revision(row.get('audio_files') or []) != audio_files_revision(expected_audio_files): | ||
| raise AudioFilesChanged('audio projection changed') | ||
| tx.update(ref, {'audio_files': retain_aliases(payload['audio_files'], row.get('audio_files') or [])}) | ||
| # A retained socket worker may still publish a later tail. Its existing | ||
| # producer fence is read-only here: repair never clears or seals it. | ||
| return None if row.get('record_upload_sessions') else True | ||
|
|
||
| return run_transactional(client, commit, transient=True) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # Durable playback projection debt (phase 5C) | ||
|
|
||
| After the bounded close rebuild retries fail, pusher hands playback-only debt | ||
| into `record_projection_rebuild_jobs/{sha256([uid,cid])}` before discarding its | ||
| socket state. Socket cleanup also hands off outstanding debt. Terminal transient/InvalidArgument batch | ||
| projection failures persist by exact owner even if the bounded close cache is full. The marker kind | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
|
||
| is `record_projection_rebuild_due`; its only other fields are owner ID, | ||
| conversation ID and request time. Repeated handoffs coalesce by owner. | ||
| Ordinary flushes add no reads or writes. Terminal marker handoffs enqueue into | ||
| one bounded background task. A separate metadata-only retry inventory preserves | ||
| overflow outside the close cache; marker failure cannot change recording-close | ||
| success. Socket cleanup has one three-second total deadline for batched writes | ||
| and retries, independent of debt count. `marker_write_failed` and bounded-list | ||
| saturation emit sanitized logs and counter outcomes. Temporary privacy holds | ||
| retain markers without a metadata update. | ||
|
|
||
| The [owning contract](../../listen_pusher_pipeline.mdx#durable-playback-projection-rebuild-debt) | ||
| defines payloads, ordering, limits, gauges, flag bindings and rollback. Process | ||
| kill, persistent Firestore failure, or saturation can still prevent handoff; | ||
| this marker is not a WAL. | ||
|
|
||
| The existing record-seal-repair CronJob runs a separate 60-second pass before | ||
| seal work, selecting at most 128 markers by request time. It uses a fresh, | ||
| socket-independent full metadata rebuild even if the incremental optimization | ||
| is disabled. Successful rebuild or revoked owner clears only the observed | ||
| marker version. Existing nonempty producer fences retain the marker after a | ||
| metadata update: a retained worker may still upload a later tail. This reads | ||
| the existing map and never clears a token or grants seal authority. Incomplete discovery or failure retains and rotates the | ||
| marker; malformed markers also rotate. Concurrent replacement wins over cleanup. Preview mode never rebuilds | ||
| or writes. Reports contain aggregate dispositions, never owner IDs or paths. | ||
| The existing CronJob suspension/runtime settings still govern execution; no | ||
| CronJob is unsuspended by this code change. | ||
|
|
||
| `projection_owner_gone` refuses a tombstone or absent row before discovery. | ||
| The cold projection transaction rechecks account/conversation privacy, | ||
| tombstones, soft deletion, sync opt-out and the prior audio-files revision | ||
| before updating only `audio_files`. A deletion or another projection arriving | ||
| during discovery cannot recreate the owner or overwrite a newer projection. | ||
| Raw-index failure cannot count as successful durable repair, including forced | ||
| socket close. Empty close discovery hands off its obligation too. Empty live | ||
| inventory retains the marker until seven days from Firestore creation, then | ||
| retires as `retired_no_objects`. A 10,000-marker maintenance ceiling also retires | ||
| confirmed empty work under pressure, never useful or privacy-held debt. Per-pass | ||
| work stays capped at 128; the collection scan caps at 10,001. Aggregate logs and | ||
| Prometheus gauges expose bounded count, observed oldest age and capacity state. | ||
| No retirement deletes audio. | ||
|
|
||
| `PUSHER_PROJECTION_DEBT_DURABLE_ENABLED` defaults **on**; `0`, `false`, or `off` | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
|
||
| disables both handoff and consumption. Rollback disables the consumer runtime | ||
| first (`recordSealRepair.projectionDebtDurableEnabled=false`), then pusher; existing markers remain inert. Re-enabling the flag resumes retained markers. | ||
| A normal socket close does not probe or clear a preexisting marker: the cold | ||
| consumer clears it after its own full rebuild, avoiding hot-path writes. | ||
|
|
||
| This adds no seal authority, producer token changes, entry clock, heartbeat, | ||
| dead-letter, audio-object mutation or PCM reads. Object discovery reads metadata | ||
| only, including the existing raw-pack index. No production measurements, IAM | ||
| checks, cloud access, live CronJob activation, end-user playback acceptance or | ||
| cost/latency claims are included. Hermetic unit tests and lifecycle emulator | ||
| scenarios exercise exhaustion, eviction, deletion during discovery and marker | ||
| replacement. Phase 5 A/B simulator changes remain outside this lane. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| """The /v1/trigger/listen WebSocket: binary device-audio ingestion, live STT and | ||
| speaker dispatch, and record-barrier custody for realtime capture. | ||
|
|
||
| Each long-lived connection supervises several concurrent tasks under a strict | ||
|
|
@@ -21,7 +21,8 @@ | |
| from utils.pusher_audio_window import append_window_run, v2_overlap_conflict, AUDIO_TIMELINE_CONTINUITY_TOLERANCE | ||
| from config.audio_custody import framed_pack | ||
| from config.audio_outbox import enabled as audio_outbox_enabled | ||
| from utils.pusher_audio_projection import PusherAudioProjection, projection_owner_gone | ||
| from utils.pusher_audio_projection import PusherAudioProjection | ||
| from utils.batch_pipeline.record_projection_rebuild import projection_owner_gone | ||
| from utils.pusher_record_barrier import PusherRecordBarrier | ||
| from utils.pusher_receive_metrics import SocketObservation, observe_work | ||
| from utils.pusher_record_finish import ( | ||
|
|
@@ -351,7 +352,7 @@ | |
| observation.close() # No await: even repeated cancellation cannot skip close accounting. | ||
|
|
||
|
|
||
| async def _run_websocket_util_trigger( | ||
| websocket: WebSocket, | ||
| uid: str, | ||
| sample_rate: int, | ||
|
|
@@ -461,7 +462,7 @@ | |
| audio_projection = PusherAudioProjection(uid) | ||
| deleted_conversations: set[str] = set() | ||
|
|
||
| async def process_durable_private_cloud_queue() -> None: | ||
| # Pending conversations hold only receipts. Hydrate at most one bounded | ||
| # contiguous batch for the legacy playback projection, preserving the | ||
| # old 60-second write cadence without retaining 60 seconds of PCM. | ||
|
|
@@ -545,6 +546,7 @@ | |
| logger.warning('Durable audio projection conflict; archive receipt retained') | ||
| except InvalidArgument as exc: | ||
| audio_projection.inventory.pop(conv_id, None) | ||
| await audio_projection.defer_rebuild(conv_id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: This handoff drops projection debt when the marker write fails because the returned Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Call Prompt for AI agents |
||
| # Custody and its generation-pinned manifest remain durable. | ||
| # Retrying a deterministic projection rejection cannot repair | ||
| # the payload, and a sticky custody failure would strand it. | ||
|
|
@@ -629,6 +631,9 @@ | |
| # counting toward the bounded retry map. | ||
| projection_retries.pop(conv_id, None) | ||
| audio_projection.inventory.pop(conv_id, None) | ||
| # The close-debt cache may be full. Persist terminal projection debt | ||
| # by its exact owner rather than relying on that bounded inventory. | ||
| await audio_projection.defer_rebuild(conv_id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a terminal batch failure occurs before Useful? React with 👍 / 👎. |
||
| await record_barrier.fail_recording(conv_id) | ||
| record_failure('projection', reason='transient') | ||
| record_fallback( | ||
|
|
@@ -753,7 +758,7 @@ | |
| record_close_requests.discard(conv_id) | ||
| record_barrier.retire_close(conv_id) | ||
|
|
||
| async def process_private_cloud_queue() -> None: | ||
| """Background task that batches private cloud sync uploads by conversation_id. | ||
|
|
||
| Chunks are accumulated per conversation and flushed when: | ||
|
|
@@ -1175,7 +1180,7 @@ | |
| # The conversation this session last streamed, for the episode moment flush at teardown. | ||
| episode_session: Dict[str, Optional[str]] = {'conversation_id': None} | ||
|
|
||
| async def receive_tasks() -> None: | ||
| nonlocal websocket_active | ||
| nonlocal inline_finish_conversation | ||
| nonlocal websocket_close_code | ||
|
|
@@ -1390,7 +1395,7 @@ | |
| websocket_active = False | ||
| audio_bytes_event.set() | ||
|
|
||
| async def process_frame(data: bytes) -> None: | ||
| nonlocal conversation_generation, custody_sequence | ||
| nonlocal audiobuffer, trigger_audiobuffer, private_cloud_chunk_start_time, private_cloud_window_opened_at | ||
| nonlocal current_conversation_id, audio_timeline_last_end | ||
|
|
@@ -1822,6 +1827,7 @@ | |
|
|
||
| all_to_cancel = [t for t in bg_main_tasks if not t.done()] | ||
| await drain_tasks(all_to_cancel, timeout=5.0, label="pusher_cleanup", cancel=True) | ||
| await audio_projection.persist_pending() | ||
|
|
||
| if application_failed and observation.reason == 'peer': | ||
| observation.reason = 'application_error' | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When regional chunk routing is enabled, the new repair pass cannot discover those chunks: this CronJob has no
BUCKET_AUDIO_DERIVEDbinding orenvFrom, although the worker Deployment binds it from.Values.audioDerivedBucket.storage.list_audio_chunks()only lists the regional bucket when that environment variable is nonempty (backend/utils/other/storage.py:1205–1211). For legacy recordings, raw packs cannot compensate becauseraw_audio_read.list_packs()excludesarrival_estimatecaptures (backend/utils/other/raw_audio_read.py:64–68). Consequently, a conversation containing both legacy-bucket and regional chunks can have its playback metadata replaced with only the legacy-bucket subset, after which the marker is retired as successfully rebuilt. Bind the same derived bucket into the repair CronJob before enabling its new consumer.AGENTS.md reference: backend/AGENTS.md:L158-L158
Useful? React with 👍 / 👎.