Skip to content

Commit d461e30

Browse files
fix(sync): enforce V2 fair-use policy parity (#10020)
## Summary - enforce the existing fresh-upload daily audio ceiling before Sync V2 can create staged or durable work - apply the stored subscription tier to fresh Sync V2 soft-cap evaluation, matching V1 - preserve durable processing when the optional subscription read fails by falling back to the default tier with shared fallback telemetry - keep the durable coordinator within the checked Pyright complexity boundary by isolating the policy-only plan lookup Closes #10002 ## Product invariants none ## Failure class Failure-Class: none This is a narrowly scoped V2 parity repair; no registered semantic failure class applies. ## Verification - `cd backend && .venv/bin/python -m pytest -q tests/unit/test_sync_v2.py tests/unit/test_sync_cloud_tasks.py tests/unit/test_fair_use_plan_aware.py` (261 passed) - `cd backend && BACKEND_PYTEST_WORKERS=4 bash test.sh` - `npm run test:sync-cloud-tasks-stack:emulator` - `backend/.venv/bin/python backend/scripts/scan_async_blockers.py --dirs backend/routers backend/utils` - `cd backend && bash scripts/typecheck.sh` (0 errors) - `.github/scripts/check_product_file_line_count_ratchet.py` against `origin/main` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/BasedHardware/omi/pull/10020?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
2 parents 09dbf20 + f65e4c4 commit d461e30

6 files changed

Lines changed: 188 additions & 5 deletions

File tree

.github/scripts/product_file_line_count_ratchet_baseline/backend-routers.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
"backend/routers/chat.py": 1577,
55
"backend/routers/developer.py": 2195,
66
"backend/routers/mcp_sse.py": 1858,
7-
"backend/routers/sync.py": 2024,
7+
"backend/routers/sync.py": 2035,
88
"backend/routers/users.py": 2046
99
},
1010
"raise_justifications": {
1111
"backend/routers/developer.py": "GET /v1/dev/user/memories serves the authoritative legacy memories collection for legacy-cohort accounts with no rollout state (#9892), keeping the narrow deny/fallback decision in the route that owns the read contract.",
1212
"backend/routers/chat.py": "PTT stereo rejection keeps the serving STT provider boundary explicit in the established chat admission owner.",
13-
"backend/routers/sync.py": "Fresh Sync admission and final Cloud Tasks ledger recovery retain their coupled task, lock, ledger, and staged-blob lifecycle in the existing ingestion owner rather than a new module.",
13+
"backend/routers/sync.py": "Fresh Sync's daily ceiling remains beside the existing fresh-admission gates, before staged audio or durable worker work is created.",
1414
"backend/routers/users.py": "GET /v1/users/subscription serializes the new mobile plus/max plans as `unlimited` for clients whose plan enum predates them, so day-one buyers read as paid instead of Free (mirrors the existing operator remap); real limits/grandfather are computed from the true plan first.",
1515
"backend/routers/mcp_sse.py": "MCP SSE memory reads route their legacy-fallback decision through the shared mcp_legacy_read_authorized helper (#9892); one import line."
1616
},

.github/scripts/product_file_line_count_ratchet_baseline/backend-utils.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
"backend/utils/memory_ingestion/pipeline.py": 1711,
66
"backend/utils/other/storage.py": 1584,
77
"backend/utils/retrieval/tools/calendar_tools.py": 1513,
8-
"backend/utils/sync/pipeline.py": 2299
8+
"backend/utils/sync/pipeline.py": 2324
99
},
1010
"raise_justifications": {
1111
"backend/utils/other/storage.py": "delete_app_logo now requires an exact app-logo bucket prefix (startswith) so a foreign URL embedding the prefix later cannot delete an unrelated object on this deletion path (David review on #9873).",
12-
"backend/utils/sync/pipeline.py": "Durable-ledger convergence remains beside the Sync pipeline's run-lock ownership and terminal-result transitions instead of splitting one recovery invariant across modules."
12+
"backend/utils/sync/pipeline.py": "Plan-aware soft-cap evaluation and its default-tier fallback remain in a small helper beside Sync's idempotent fresh-speech metering rather than overgrowing the durable worker coordinator."
1313
},
1414
"threshold": 1500
1515
}

backend/routers/sync.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,17 @@ async def sync_local_files_v2(
967967
request_id=x_request_id if isinstance(x_request_id, str) else None,
968968
cloud_trace_context=x_cloud_trace_context if isinstance(x_cloud_trace_context, str) else None,
969969
)
970+
if await run_blocking(db_executor, is_daily_audio_ceiling_exceeded, uid):
971+
logger.info('sync_v2: daily audio ceiling reached uid=%s', uid)
972+
return await _fair_use_restriction_response(
973+
uid=uid,
974+
retry_after=_retry_after_until_next_utc_day(),
975+
client_platform=client_device_context.platform,
976+
device_hash=client_device_context.device_hash,
977+
app_version=client_device_context.app_version,
978+
request_id=x_request_id if isinstance(x_request_id, str) else None,
979+
cloud_trace_context=x_cloud_trace_context if isinstance(x_cloud_trace_context, str) else None,
980+
)
970981

971982
should_lock = not await run_blocking(critical_executor, has_transcription_credits, uid)
972983

backend/tests/unit/test_sync_cloud_tasks.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,7 @@ def _submit_with_context(executor, fn, *args, **kwargs):
10061006

10071007
sys.modules['utils.fair_use'].is_hard_restricted = MagicMock(return_value=False)
10081008
sys.modules['utils.fair_use'].get_hard_restriction_status = MagicMock(return_value=(False, None))
1009+
sys.modules['utils.fair_use'].is_daily_audio_ceiling_exceeded = MagicMock(return_value=False)
10091010
sys.modules['utils.fair_use'].is_dg_budget_exhausted = MagicMock(return_value=False)
10101011
sys.modules['utils.fair_use'].get_enforcement_stage = MagicMock(return_value='off')
10111012
sys.modules['utils.fair_use'].FAIR_USE_ENABLED = False
@@ -2018,6 +2019,89 @@ def _rmtree_raises(*args, **kwargs):
20182019
sys.modules[mod_name] = orig
20192020

20202021

2022+
@pytest.mark.asyncio
2023+
async def test_fresh_admission_daily_ceiling_prevents_staging_or_dispatch():
2024+
"""A fresh V2 upload over the hard ceiling must leave its WAL audio untouched."""
2025+
from starlette.datastructures import UploadFile
2026+
2027+
module, saved_modules, mock_sync_jobs, BytesIO, _, _ = _load_sync_router_for_fast_path()
2028+
module.is_daily_audio_ceiling_exceeded = MagicMock(return_value=True)
2029+
module._fair_use_restriction_response = AsyncMock(
2030+
return_value=module.JSONResponse(status_code=429, content={'code': 'fair_use_restricted'})
2031+
)
2032+
module.start_background_task = MagicMock()
2033+
module.classify_sync_lane = MagicMock(
2034+
return_value=types.SimpleNamespace(
2035+
lane=module.SyncLane.FRESH,
2036+
trust=types.SimpleNamespace(value='device_bound'),
2037+
reason='recent_capture',
2038+
maximum_age_seconds=60,
2039+
automatic_recovery_allowed=True,
2040+
)
2041+
)
2042+
2043+
try:
2044+
upload = UploadFile(filename='test.opus', file=BytesIO(b'\x00' * 10))
2045+
response = await module.sync_local_files_v2(files=[upload], uid='test-uid')
2046+
2047+
assert response.status_code == 429
2048+
assert json.loads(response.body) == {'code': 'fair_use_restricted'}
2049+
module.is_daily_audio_ceiling_exceeded.assert_called_once_with('test-uid')
2050+
module._fair_use_restriction_response.assert_awaited_once()
2051+
restriction_kwargs = module._fair_use_restriction_response.await_args.kwargs
2052+
assert restriction_kwargs['uid'] == 'test-uid'
2053+
assert restriction_kwargs['retry_after'] > 0
2054+
module._retrieve_file_paths_v2.assert_not_called()
2055+
mock_sync_jobs.create_sync_job.assert_not_called()
2056+
module.claim_sync_content.assert_not_called()
2057+
module.enqueue_sync_job.assert_not_called()
2058+
module.start_background_task.assert_not_called()
2059+
module.has_transcription_credits.assert_not_called()
2060+
finally:
2061+
sys.modules.pop('routers.sync', None)
2062+
sys.modules.pop('utils.sync.pipeline', None)
2063+
for mod_name, orig in saved_modules.items():
2064+
if orig is None:
2065+
sys.modules.pop(mod_name, None)
2066+
else:
2067+
sys.modules[mod_name] = orig
2068+
2069+
2070+
@pytest.mark.asyncio
2071+
async def test_backfill_admission_does_not_consume_fresh_daily_ceiling():
2072+
"""Historical recovery keeps its independent pacing policy, even above the live ceiling."""
2073+
from starlette.datastructures import UploadFile
2074+
2075+
module, saved_modules, mock_sync_jobs, BytesIO, _, _ = _load_sync_router_for_fast_path()
2076+
module.is_daily_audio_ceiling_exceeded = MagicMock(return_value=True)
2077+
module.start_background_task = MagicMock()
2078+
module.classify_sync_lane = MagicMock(
2079+
return_value=types.SimpleNamespace(
2080+
lane=module.SyncLane.BACKFILL,
2081+
trust=types.SimpleNamespace(value='legacy'),
2082+
reason='historical_recovery',
2083+
maximum_age_seconds=24 * 60 * 60,
2084+
automatic_recovery_allowed=True,
2085+
)
2086+
)
2087+
2088+
try:
2089+
upload = UploadFile(filename='test.opus', file=BytesIO(b'\x00' * 10))
2090+
response = await module.sync_local_files_v2(files=[upload], uid='test-uid')
2091+
2092+
assert response.status_code == 202
2093+
module.is_daily_audio_ceiling_exceeded.assert_not_called()
2094+
mock_sync_jobs.create_sync_job.assert_called_once()
2095+
finally:
2096+
sys.modules.pop('routers.sync', None)
2097+
sys.modules.pop('utils.sync.pipeline', None)
2098+
for mod_name, orig in saved_modules.items():
2099+
if orig is None:
2100+
sys.modules.pop(mod_name, None)
2101+
else:
2102+
sys.modules[mod_name] = orig
2103+
2104+
20212105
@pytest.mark.asyncio
20222106
async def test_sync_dispatch_cloud_tasks_success_increments_attempts(monkeypatch):
20232107
from starlette.datastructures import UploadFile

backend/tests/unit/test_sync_v2.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import pytest
2525
from fastapi.routing import APIRoute
26+
from models.users import PlanType
2627
from utils.executors import run_blocking as _production_run_blocking
2728

2829
PIPELINE_PATH = os.path.join(os.path.dirname(__file__), '..', '..', 'utils', 'sync', 'pipeline.py')
@@ -2372,6 +2373,67 @@ def _vad_with_segments(path, segmented_paths, errors):
23722373
finally:
23732374
self._cleanup(stubs['saved_modules'])
23742375

2376+
@pytest.mark.asyncio
2377+
@pytest.mark.parametrize(
2378+
('subscription', 'expected_plan', 'lookup_error'),
2379+
[
2380+
(types.SimpleNamespace(plan=PlanType.unlimited_v2), PlanType.unlimited_v2, None),
2381+
(types.SimpleNamespace(plan=PlanType.basic), PlanType.basic, None),
2382+
(None, None, None),
2383+
(None, None, RuntimeError('subscription store unavailable')),
2384+
],
2385+
ids=['unlimited', 'basic', 'missing-subscription', 'subscription-read-failure'],
2386+
)
2387+
async def test_fresh_soft_caps_use_the_current_subscription_plan(self, subscription, expected_plan, lookup_error):
2388+
"""Queued fresh work applies the persisted plan instead of the default cap tier."""
2389+
module, stubs = self._load_sync_module()
2390+
try:
2391+
pipeline = stubs['pipeline']
2392+
pipeline.decode_files_to_wav = MagicMock(return_value=['/tmp/w.wav'])
2393+
pipeline._cleanup_files = MagicMock()
2394+
2395+
def _vad_with_segments(path, segmented_paths, errors):
2396+
segmented_paths.add('/tmp/seg_1700000001.wav')
2397+
2398+
speech_totals = {'daily_ms': 5_000, 'three_day_ms': 5_000, 'weekly_ms': 5_000}
2399+
pipeline.retrieve_vad_segments = _vad_with_segments
2400+
pipeline.get_wav_duration = MagicMock(return_value=5.0)
2401+
pipeline.FAIR_USE_ENABLED = True
2402+
pipeline.FAIR_USE_RESTRICT_DAILY_DG_MS = 0
2403+
pipeline.record_speech_ms = MagicMock()
2404+
pipeline.get_rolling_speech_ms = MagicMock(return_value=speech_totals)
2405+
pipeline.check_soft_caps = MagicMock(return_value=[])
2406+
pipeline.users_db = MagicMock()
2407+
pipeline.users_db.get_existing_user_subscription = MagicMock(return_value=subscription)
2408+
if lookup_error:
2409+
pipeline.users_db.get_existing_user_subscription.side_effect = lookup_error
2410+
pipeline.users_db.get_user_transcription_preferences = MagicMock(return_value={})
2411+
pipeline.users_db.get_user_private_cloud_sync_enabled = MagicMock(return_value=False)
2412+
pipeline.users_db.get_data_protection_level = MagicMock(return_value=None)
2413+
pipeline.build_person_embeddings_cache = MagicMock(return_value={})
2414+
pipeline.process_segment = MagicMock()
2415+
pipeline.record_dg_usage_ms = MagicMock()
2416+
pipeline.record_usage = MagicMock()
2417+
2418+
await module._run_full_pipeline_background_async('j-plan', 'uid', ['/tmp/f.opus'], 'omi', False, '/tmp/job')
2419+
2420+
pipeline.users_db.get_existing_user_subscription.assert_called_once_with('uid')
2421+
pipeline.check_soft_caps.assert_called_once_with('uid', speech_totals=speech_totals, plan=expected_plan)
2422+
stubs['sync_jobs'].finalize_sync_job.assert_called_once()
2423+
if lookup_error:
2424+
pipeline.record_fallback.assert_called_once_with(
2425+
component='other',
2426+
from_mode='subscription_plan',
2427+
to_mode='default_cap',
2428+
reason='policy',
2429+
outcome='degraded',
2430+
log=pipeline.logger,
2431+
)
2432+
else:
2433+
pipeline.record_fallback.assert_not_called()
2434+
finally:
2435+
self._cleanup(stubs['saved_modules'])
2436+
23752437
@pytest.mark.asyncio
23762438
async def test_partial_segment_failure_completes(self):
23772439
"""Partial segment failure must complete (not fail) with error count."""
@@ -2789,6 +2851,7 @@ def _submit_with_context(executor, fn, *args, **kwargs):
27892851
# Set up fair_use defaults
27902852
sys.modules['utils.fair_use'].is_hard_restricted = MagicMock(return_value=False)
27912853
sys.modules['utils.fair_use'].get_hard_restriction_status = MagicMock(return_value=(False, None))
2854+
sys.modules['utils.fair_use'].is_daily_audio_ceiling_exceeded = MagicMock(return_value=False)
27922855
sys.modules['utils.fair_use'].is_dg_budget_exhausted = MagicMock(return_value=False)
27932856
sys.modules['utils.fair_use'].get_enforcement_stage = MagicMock(return_value='off')
27942857
sys.modules['utils.fair_use'].FAIR_USE_ENABLED = False

backend/utils/sync/pipeline.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
upload_audio_chunk,
9696
upload_syncing_temporal_file,
9797
)
98+
from utils.observability.fallback import record_fallback
9899
from utils.observability.transcription import record_sync_transcription_outcome
99100
from utils.speaker_assignment import process_speaker_assigned_segments
100101
from utils.speaker_identification import detect_speaker_from_text
@@ -156,6 +157,27 @@ def _bounded_exception_type(error: BaseException) -> str:
156157
return name if name.replace('_', '').isalnum() and len(name) <= 64 else 'Exception'
157158

158159

160+
async def _resolve_fair_use_soft_cap_plan(uid: str):
161+
"""Return the stored plan, falling back to the default soft-cap tier on read failure."""
162+
try:
163+
fair_use_sub = await run_blocking(db_executor, users_db.get_existing_user_subscription, uid)
164+
return fair_use_sub.plan if fair_use_sub else None
165+
except Exception as e:
166+
logger.warning(
167+
'event=sync_fair_use outcome=subscription_plan_fallback exception_type=%s',
168+
_bounded_exception_type(e),
169+
)
170+
record_fallback(
171+
component='other',
172+
from_mode='subscription_plan',
173+
to_mode='default_cap',
174+
reason='policy',
175+
outcome='degraded',
176+
log=logger,
177+
)
178+
return None
179+
180+
159181
def _bounded_sync_failure_reason(reason: str | None) -> str:
160182
return reason if reason in _SYNC_FAILURE_REASON_CODES else 'other'
161183

@@ -1789,8 +1811,11 @@ async def _run_full_pipeline_background_async(
17891811
raise_on_error=bool(content_id),
17901812
)
17911813
if sync_lane == SyncLane.FRESH.value:
1814+
fair_use_plan = await _resolve_fair_use_soft_cap_plan(uid)
17921815
speech_totals = await run_blocking(db_executor, get_rolling_speech_ms, uid)
1793-
triggered_caps = await run_blocking(db_executor, check_soft_caps, uid, speech_totals=speech_totals)
1816+
triggered_caps = await run_blocking(
1817+
db_executor, check_soft_caps, uid, speech_totals=speech_totals, plan=fair_use_plan
1818+
)
17941819
if triggered_caps:
17951820
logger.info(
17961821
'event=sync_fair_use outcome=soft_cap_triggered cap_count=%d',

0 commit comments

Comments
 (0)