Skip to content

Commit 2da0374

Browse files
test: add durable finalization, Sync, and Rewind gauntlets (#9981)
## Summary - Add a non-production Rewind artifact/recovery bridge gauntlet covering privacy admission, HEVC, SQLite, readback, recovery, and cleanup. - Make memory-read fixtures inject their reference time so CI never expires a fixed visibility window. - Keep the shared vector-fixture seam signature-aligned with production's explicit `now` snapshot, including callers that inject a custom fixture clock. - Fix Rewind lifecycle ownership: cleanup, writes, and finalization are generation-fenced; concurrent shutdown flushes join the active trailer write, cancellation surfaces failure, and chunk paths are collision-proof and recover at their absolute capture time. - Extend the upstream two-process Listen Cloud Tasks gauntlet with a deterministic public REST finalization race: four stale-read requests create one outbox job and one named task, prove the three `AlreadyExists` handoffs, then rehydrate that task after listener restart and prove exactly-once worker completion. - Repair the Firestore contention the new REST race exposed in CI: recreate the transaction and SDK wrapper for bounded `Aborted` retries, preserve the atomic outbox boundary, and return the existing retryable 503 contract only after that budget is exhausted. Failed gauntlet jobs now retain and tail their synthetic backend logs for actionable diagnosis. - Adopt the stronger upstream Sync (#10008) and Listen (#10000) gauntlets during rebase instead of restoring the older overlapping harnesses. ## Final verification (rebased onto current `origin/main`) - `BACKEND_PYTEST_WORKERS=4 bash backend/test.sh` — all 675 backend unit test files passed - `backend/testing/listen_pusher_stack/run.sh --keep --state-dir /tmp/omi-listen-pusher-final-head.X326dN` — passed all inline and Cloud Tasks scenarios, including the deterministic REST/restart case - `bash backend/test.sh` — all 675 backend unit test files passed again after the contention repair - `backend/testing/listen_pusher_stack/run.sh --state-dir /tmp/omi-listen-pusher-contention-fix` — passed all inline and Cloud Tasks scenarios after the contention repair - `bash backend/scripts/typecheck.sh` — 0 errors - `cd desktop/macos && bash test.sh` — launcher tests, Rust tests, and isolated Swift suites passed - `make preflight` — deterministic CI manifest passed - `scripts/pr-preflight --pr-body-file /tmp/omi-gauntlet-pr-body.md` and `scripts/failure-class validate --base origin/main --head HEAD --pr-body-file /tmp/omi-gauntlet-pr-body.md` — passed The former PR head was GitHub-conflicted, which suppressed normal `pull_request` runs. This branch is rebased cleanly onto current `origin/main`; these are the final-head checks before its fresh CI run. ## Guard scope The four-party read barrier intentionally lives only in the Listen gauntlet entrypoint, rather than in a shared or production primitive. It coordinates a controllable harness race after the real authenticated lookup; the production route, Firestore transaction, Cloud Tasks task construction, listener restart, and worker all remain unmodified. It extends the recovery surface introduced by merged [#10000](#10000). ## Product invariants affected - INV-MEM-1 - INV-MEM-2 ## Failure class Failure-Class: none The Rewind lifecycle defects and bounded Firestore transaction-contention recovery have behavioral regression guards, but no existing semantic failure class describes either failure boundary.
2 parents bf16cb5 + 1d3381b commit 2da0374

39 files changed

Lines changed: 1796 additions & 173 deletions

.github/workflows/backend-hermetic-e2e.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,17 @@ jobs:
9898
sudo apt-get install --yes redis-server
9999
100100
- name: Run listen to pusher stack gauntlet
101-
run: npm run test:listen-pusher-stack:emulator
101+
run: npm run test:listen-pusher-stack:emulator -- --state-dir "$RUNNER_TEMP/listen-pusher-stack"
102+
103+
- name: Show listen gauntlet backend logs on failure
104+
if: failure()
105+
run: |
106+
state_dir="$RUNNER_TEMP/listen-pusher-stack"
107+
if [[ -d "$state_dir" ]]; then
108+
find "$state_dir" -type f -name backend.log -print -exec tail -n 160 {} \;
109+
else
110+
echo "Listen gauntlet state directory was not retained."
111+
fi
102112
103113
sync-cloud-tasks-stack-gauntlet:
104114
name: Sync Cloud Tasks Stack Gauntlet

.github/workflows/desktop-backend-contracts.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ on:
2222
- "desktop/macos/e2e/**"
2323
- "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift"
2424
- "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift"
25+
- "desktop/macos/Desktop/Sources/Rewind/Core/RewindArtifactGauntlet.swift"
2526

2627
jobs:
2728
desktop-core-e2e-t0:

backend/database/conversation_finalization_jobs.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from database import conversations as conversations_db
1717
from database._client import document_id_from_seed, get_firestore_client
18+
from database.firestore_transaction_retry import run_with_transaction_contention_retry
1819

1920
CONVERSATIONS_COLLECTION = 'conversations'
2021
FINALIZATION_JOBS_COLLECTION = 'conversation_finalization_jobs'
@@ -252,19 +253,30 @@ def create_or_get_finalization_intent(
252253
) -> FinalizationIntent:
253254
client = _client(firestore_client)
254255
conversation_ref = _conversation_ref(client, uid, conversation_id)
255-
transaction = client.transaction()
256-
transactional = firestore.transactional(_create_or_get_finalization_intent_txn)
257-
return transactional(
258-
transaction,
259-
conversation_ref,
260-
client.collection(FINALIZATION_JOBS_COLLECTION),
261-
uid,
262-
conversation_id,
263-
requires_byok,
264-
finalization_admission,
265-
_now(),
266-
force_process=force_process,
267-
extra_updates=extra_updates,
256+
jobs_collection = client.collection(FINALIZATION_JOBS_COLLECTION)
257+
258+
def create_intent_in_transaction(transaction: Any) -> FinalizationIntent:
259+
# The Firestore SDK's transactional wrapper retains retry state. Build
260+
# it for this outer attempt so concurrent REST finalizers always get a
261+
# fresh transaction and wrapper after read-time contention.
262+
transactional = firestore.transactional(_create_or_get_finalization_intent_txn)
263+
return transactional(
264+
transaction,
265+
conversation_ref,
266+
jobs_collection,
267+
uid,
268+
conversation_id,
269+
requires_byok,
270+
finalization_admission,
271+
_now(),
272+
force_process=force_process,
273+
extra_updates=extra_updates,
274+
)
275+
276+
return run_with_transaction_contention_retry(
277+
client.transaction,
278+
create_intent_in_transaction,
279+
operation_name='conversation_finalization_intent',
268280
)
269281

270282

backend/models/memory_search_gateway.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def hydrate_and_filter_vector_hits(
7272
mode: SearchMode,
7373
required_projection_commit_id: str,
7474
required_account_generation: int,
75+
now: Optional[datetime] = None,
7576
) -> SearchGatewayResult:
7677
"""Fail-closed vector gateway.
7778
@@ -231,9 +232,9 @@ def hydrate_and_filter_vector_hits(
231232
)
232233
continue
233234
access = (
234-
is_archive_access_eligible(item, policy)
235+
is_archive_access_eligible(item, policy, now=now)
235236
if mode == SearchMode.archive_explicit
236-
else is_default_access_eligible(item, policy)
237+
else is_default_access_eligible(item, policy, now=now)
237238
)
238239
if not access.allowed:
239240
decisions[hit.memory_id] = SearchDecision.access_denied

backend/routers/memory_product.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ def search_vector_memory(
174174
limit=limit,
175175
required_projection_commit_id=rollout.vector_projection_commit_id,
176176
required_account_generation=rollout.rollout_capabilities.account_generation,
177+
now=_current_time(),
177178
)
178179
except ValueError as exc:
179180
raise HTTPException(status_code=400, detail=str(exc)) from exc

backend/testing/listen_pusher_stack/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,19 @@ Scenarios:
6969
3. a stale empty desktop recording is removed by the next-session lifecycle path and creates no job;
7070
4. a pusher process loses the first 104 before claim, is restarted, and the
7171
live backend session replays the same job ID and dispatch generation exactly once.
72-
5. a session closes during the deferred pending-finalization window; the real
72+
5. concurrent public `POST /v1/conversations/{id}/finalize` retries produce one
73+
opaque named task and one outbox job, prove the `AlreadyExists` boundary,
74+
then survive listener restart before the detached worker completes and safely
75+
ACKs a duplicate delivery. A bounded test-entrypoint read barrier makes the
76+
intended stale-read race deterministic without replacing the route,
77+
lifecycle transaction, or task construction;
78+
6. a session closes during the deferred pending-finalization window; the real
7379
recovery path from #9960 enqueues one opaque Cloud Tasks task, then a real
7480
worker retry preserves `processing` until it completes the same job;
75-
6. a worker exhausting its two-attempt test budget atomically dead-letters the
81+
7. a worker exhausting its two-attempt test budget atomically dead-letters the
7682
job and marks the still-current conversation `failed`/`discarded`, while a
7783
later duplicate delivery is fenced;
78-
7. an integration failure after processing retries only durable fanout, never
84+
8. an integration failure after processing retries only durable fanout, never
7985
re-runs completed conversation processing.
8086

8187
The inline compatibility coverage deliberately triggers stale live-session

backend/testing/listen_pusher_stack/cloud_tasks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ def create_task(self, *, parent: str, task: Any) -> Any:
138138
raise RuntimeError('finalization task name does not match its opaque durable identity')
139139
with _task_event_lock():
140140
if task_name in _read_task_names():
141+
_append_event(
142+
{
143+
'event': 'task_already_exists',
144+
'task_name': task_name,
145+
'payload': payload,
146+
}
147+
)
141148
raise AlreadyExists(f'loopback task already exists: {task_name}')
142149
_append_event(
143150
{

backend/testing/listen_pusher_stack/finalizer_leaves.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def _consume_failure(stage: str, conversation_id: str, **metadata: Any) -> bool:
5454
return True
5555

5656

57-
def _offline_process_conversation(uid: str, _language: str, conversation: Any, **_kwargs: Any) -> Any:
57+
def _offline_process_conversation(uid: str, _language: str, conversation: Any, **kwargs: Any) -> Any:
5858
conversation_id = str(conversation.id)
5959
if _consume_failure('process', conversation_id):
6060
raise RuntimeError('controlled finalization processing failure')
@@ -67,6 +67,8 @@ def _offline_process_conversation(uid: str, _language: str, conversation: Any, *
6767
'outcome': 'completed',
6868
'conversation_id': conversation_id,
6969
'persisted': bool(persisted),
70+
'force_process': bool(kwargs.get('force_process')),
71+
'defer_memory_extraction': bool(kwargs.get('defer_memory_extraction')),
7072
}
7173
)
7274
return conversation
Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,69 @@
11
"""Instrumented listener entrypoint for durable finalization scenarios.
22
3-
The production FastAPI application and task construction remain real. Only the
4-
Cloud Tasks transport client is replaced by a strict loopback boundary.
3+
The production FastAPI application and task construction remain real. Cloud
4+
Tasks is the only external transport replaced; an opt-in gate merely
5+
coordinates real route lookups for a deterministic concurrency scenario.
56
"""
67

8+
import os
9+
from threading import Barrier, BrokenBarrierError, Lock
10+
711
from testing.listen_pusher_stack.cloud_tasks import install_loopback_tasks_client
812

913
install_loopback_tasks_client()
1014

1115
from main import app # noqa: E402
16+
17+
18+
def _install_rest_finalization_race_barrier() -> None:
19+
"""Force a bounded stale-read race before the real finalization transaction.
20+
21+
The live gauntlet sends concurrent public REST calls, but scheduler timing
22+
alone cannot guarantee every handler reads ``in_progress`` before the
23+
winning Firestore transaction changes it to ``processing``. This opt-in
24+
harness seam gates only the first N target reads after they have used the
25+
production lookup. The route, auth, lifecycle transaction, and Cloud Tasks
26+
call therefore stay real while the intended named-task race is repeatable.
27+
"""
28+
uid = os.getenv('OMI_STACK_FINALIZATION_RACE_UID', '')
29+
conversation_id = os.getenv('OMI_STACK_FINALIZATION_RACE_CONVERSATION_ID', '')
30+
raw_parties = os.getenv('OMI_STACK_FINALIZATION_RACE_PARTIES', '')
31+
if not any((uid, conversation_id, raw_parties)):
32+
return
33+
if not all((uid, conversation_id, raw_parties)):
34+
raise RuntimeError('REST finalization race barrier requires uid, conversation ID, and party count')
35+
try:
36+
parties = int(raw_parties)
37+
except ValueError as error:
38+
raise RuntimeError('REST finalization race barrier party count must be an integer') from error
39+
if parties < 2:
40+
raise RuntimeError('REST finalization race barrier needs at least two parties')
41+
42+
from routers import conversations as conversations_router
43+
44+
original_lookup = conversations_router._get_valid_conversation_by_id
45+
barrier = Barrier(parties)
46+
lock = Lock()
47+
remaining = parties
48+
49+
def lookup_with_race_gate(request_uid: str, request_conversation_id: str):
50+
nonlocal remaining
51+
snapshot = original_lookup(request_uid, request_conversation_id)
52+
if request_uid != uid or request_conversation_id != conversation_id:
53+
return snapshot
54+
with lock:
55+
should_wait = remaining > 0
56+
if should_wait:
57+
remaining -= 1
58+
if not should_wait:
59+
return snapshot
60+
try:
61+
barrier.wait(timeout=10.0)
62+
except BrokenBarrierError as error:
63+
raise RuntimeError('REST finalization race barrier did not receive every request') from error
64+
return snapshot
65+
66+
conversations_router._get_valid_conversation_by_id = lookup_with_race_gate
67+
68+
69+
_install_rest_finalization_race_barrier()

0 commit comments

Comments
 (0)