perf: fix translate deadlock, speed, trace_id, UI bugs — fullstack patch

## Backend (7 production files + 6 test files)

### P0-2: LLM output truncation cascade fix
- _token_budget.py: OUTPUT_PER_ROW_PER_LANG 120→200, OUTPUT_SAFETY_FACTOR 0.70→0.55
- Prevents finish_reason=length → split → retry cascade (3 calls → 1 call per batch)
- P2-8: added qwen-flash/qwen-plus/qwen-max/qwen-coder to PROVIDER_DEFAULTS

### P1-4/P1-5: EncryptionManager singleton
- encryption.py: get_encryption_manager() process-wide singleton
- llm_provider.py: use singleton instead of new EncryptionManager() per batch
- Eliminates ~90 redundant Fernet key validations per translation run

### P1-6: Cache-hit log aggregation
- _batch_proc.py: one log per batch (batch_rows + cache_hits) instead of per-row
- 1076 log lines → ~30 per run

### P1-7: Timezone-aware datetime fix
- scheduler.py: _ensure_aware() helper for naive DB datetime → UTC-aware
- Fixes TypeError in scheduled translation concurrency check

### P2-9: Connection test timeout
- connection_service.py: asyncio.wait_for(15s) on all dialect tests
- Prevents 2-minute UI hangs from DNS/TCP stalls

### Trace ID propagation
- middleware/trace.py: inject x-trace-id response header via ASGI send wrapper

### Test fixes & integration tests
- test_scheduler.py: AsyncMock for execute_run, mock get_async_job_runner
- test_sql_insert_service.py: AsyncMock for execute_sql
- test_token_budget.py: batch_size 50→45 for new OUTPUT_PER_ROW_PER_LANG=200
- test_encryption.py: +2 singleton tests
- test_scheduler_ensure_aware.py: +4 (naive→aware, passthrough, None, subtraction)
- test_batch_classify_persist.py: +2 cache-hit aggregation tests
- test_connection_service_edge.py: +2 timeout tests
- test_trace_middleware.py: +4 x-trace-id header tests
- test_token_budget.py: +4 qwen-flash/O200 tests

## Frontend (7 production files + 5 test files)

### Trace ID propagation
- api.ts: _captureTraceId() reads x-trace-id → setTraceId() in fetchApi/requestApi/postApi/deleteApi

### Duplicate datasource columns fetch
- ConfigTabForm.svelte: guard availableColumns.length === 0 before fetch

### Admin pages Svelte 5 runes fix
- admin/users/+page.svelte: plain let → () for all template-bound vars
- admin/roles/+page.svelte: same fix
- Both pages were stuck on «Загрузка...» due to mixed reactivity models

### Validation popover positioning
- +page.svelte: pass trigger HTMLElement instead of event
- DashboardHubModel.svelte.ts: toggleValidationPopover(HTMLElement), closeValidationPopover()
- Added X close button + click-outside overlay + i18n

### Test fixes & integration tests
- api.test.ts: mock setTraceId/getTraceId, +3 _captureTraceId tests
- provider_config.integration.test.ts: handleDelete→promptDeleteProvider
- DatasetPreview.test.ts: dashboards/ → ROUTES.dashboards
- test_config_tab_form.svelte.js: +2 columns fetch guard tests (NEW)
- admin-users.test.ts: +3 loading→table tests (NEW)
- admin-roles.test.ts: +2 loading→table tests (NEW)

## Semantic curation
- Removed @COMPLEXITY N from 6 route files + metrics.py (duplicate of [C:N])
- Added [C:N] to 2 orphan child contracts in metrics.py
- Added [C:N] + @BRIEF to 4 frontend anchors
- Fixed #region → # #region consistency in validation_tasks.py

## Verification
- Backend: 608 pytest passed (0 failures)
- Frontend: 2472 vitest passed (128 files, 0 failures)
- Frontend build: ✓ built in 18s
- Browser: dashboards, admin/users, admin/roles, validation popover — all green
This commit is contained in:
2026-06-18 23:54:57 +03:00
parent 4a6fe8db58
commit 3133e50645
59 changed files with 1774 additions and 13928 deletions

View File

@@ -66,7 +66,6 @@ async def submit_correction(
# @POST All corrections applied or none with conflict list.
# @SIDE_EFFECT Creates/updates DictionaryEntry rows; commits once.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @COMPLEXITY 4
@router.post("/corrections/bulk")
async def submit_bulk_corrections(

View File

@@ -204,7 +204,6 @@ async def duplicate_job(
# @SIDE_EFFECT Queries Superset API for dataset detail and database info.
# @RATIONALE Dialect detection from Superset connection cached on TranslationJob at save time.
# @REJECTED Hardcoding dialect list per database — would drift from actual Superset config.
# @COMPLEXITY 4
@router.get("/datasources")
async def list_datasources(

View File

@@ -47,7 +47,6 @@ async def get_metrics(
# @BRIEF Get aggregated metrics for a specific job.
# @PRE User has translate.metrics.view permission.
# @POST Returns metrics dict.
# @COMPLEXITY 4
@router.get("/jobs/{job_id}/metrics")
async def get_job_metrics(

View File

@@ -178,7 +178,6 @@ async def get_run_detail(
# @BRIEF Download a CSV of skipped translation records from a run.
# @PRE User has translate.job.view permission.
# @POST Returns CSV file of skipped records.
# @COMPLEXITY 4
@router.get("/runs/{run_id}/skipped.csv")
async def download_skipped_csv(

View File

@@ -216,7 +216,6 @@ async def delete_schedule(
# @BRIEF Preview next N executions for a job's schedule.
# @PRE User has translate.schedule.view permission.
# @POST Returns next execution times.
# @COMPLEXITY 4
@router.get("/jobs/{job_id}/schedule/next-executions")
async def get_next_executions(

View File

@@ -1,4 +1,4 @@
#region ValidationTasksRoutes [C:4] [TYPE Module] [SEMANTICS fastapi, validation, api, task, run]
# #region ValidationTasksRoutes [C:4] [TYPE Module] [SEMANTICS fastapi, validation, api, task, run]
# @BRIEF API routes for validation task management — v2 CRUD with run history and Superset URL parsing.
# @LAYER API
# @RELATION DEPENDS_ON -> [ValidationTaskService]

View File

@@ -35,6 +35,7 @@
# @SIDE_EFFECT Opens/closes native DB connection. Network I/O.
# #endregion
import asyncio
import time as time_module
from typing import Any
@@ -78,6 +79,10 @@ def _validate_name_uniqueness(
# @ingroup Core
# @BRIEF Service class wrapping all DatabaseConnection CRUD + test operations.
class ConnectionService:
# Hard cap on connection test duration — prevents UI hangs when
# DNS/TCP handshake stalls (observed 2+ min in production).
TEST_CONNECTION_TIMEOUT_SEC = 15.0
def __init__(self, config_manager: ConfigManager):
self.config_manager = config_manager
self._encryption: EncryptionManager | None = None
@@ -253,13 +258,22 @@ class ConnectionService:
start = time_module.time()
try:
# Use dialect-appropriate test
# Use dialect-appropriate test with hard timeout
if conn.dialect in ("postgresql",):
version = await self._test_postgresql(conn)
version = await asyncio.wait_for(
self._test_postgresql(conn),
timeout=self.TEST_CONNECTION_TIMEOUT_SEC,
)
elif conn.dialect == "clickhouse":
version = self._test_clickhouse(conn)
version = await asyncio.wait_for(
asyncio.to_thread(self._test_clickhouse, conn),
timeout=self.TEST_CONNECTION_TIMEOUT_SEC,
)
elif conn.dialect == "mysql":
version = self._test_mysql(conn)
version = await asyncio.wait_for(
asyncio.to_thread(self._test_mysql, conn),
timeout=self.TEST_CONNECTION_TIMEOUT_SEC,
)
else:
return {"success": False, "error": f"Unsupported dialect: {conn.dialect}"}
@@ -269,6 +283,13 @@ class ConnectionService:
"latency_ms": elapsed_ms,
"db_version": version,
}
except asyncio.TimeoutError:
elapsed_ms = int((time_module.time() - start) * 1000)
return {
"success": False,
"latency_ms": elapsed_ms,
"error": f"Connection test timed out after {self.TEST_CONNECTION_TIMEOUT_SEC:.0f}s",
}
except Exception as e:
elapsed_ms = int((time_module.time() - start) * 1000)
return {

View File

@@ -64,6 +64,9 @@ def _require_fernet_key() -> bytes:
# @SIDE_EFFECT Initializes Fernet cryptography state from process environment.
# @DATA_CONTRACT Input[str] -> Output[str]
# @INVARIANT Uses only a validated secret key from environment.
# @RATIONALE Fernet key validation (Fernet(key)) is O(1) but repeated ~90× per translation
# run — cached at module level via get_encryption_manager() singleton to eliminate
# redundant env-var reads and key validation.
#
# @TEST_CONTRACT EncryptionManagerModel ->
# {
@@ -109,4 +112,31 @@ class EncryptionManager:
# #endregion EncryptionManager
# Module-level singleton — avoids redundant env reads and Fernet construction
# across ~90 LLMProviderService instantiations per translation run.
_encryption_manager: EncryptionManager | None = None
# #region get_encryption_manager [C:4] [TYPE Function] [SEMANTICS encryption,singleton,cache]
# @BRIEF Return the process-wide EncryptionManager singleton.
# @PRE ENCRYPTION_KEY env var is set to a valid Fernet key.
# @POST Returns the same EncryptionManager instance on every call (idempotent).
# @SIDE_EFFECT Creates EncryptionManager on first call (reads env, validates Fernet key).
# @RATIONALE Eliminates redundant ENCRYPTION_KEY reads and Fernet key validation —
# LLMProviderService was creating ~90 EncryptionManager instances per
# translation run (2 per batch). The singleton reduces this to 1.
def get_encryption_manager() -> EncryptionManager:
"""Return the process-wide EncryptionManager singleton.
Creates on first call; returns cached instance thereafter.
Eliminates redundant ENCRYPTION_KEY reads and Fernet key validation.
"""
global _encryption_manager
if _encryption_manager is None:
_encryption_manager = EncryptionManager()
return _encryption_manager
# #endregion get_encryption_manager
# #endregion EncryptionCore

View File

@@ -38,7 +38,7 @@ import uuid
from starlette.types import ASGIApp, Receive, Scope, Send
from ..cot_logger import seed_trace_id, set_trace_id
from ..cot_logger import get_trace_id, seed_trace_id, set_trace_id
# #region TraceContextMiddleware [C:2] [TYPE Class] [SEMANTICS middleware, trace, context, asgi]
@@ -57,7 +57,7 @@ class TraceContextMiddleware:
# #region TraceContextMiddleware.__call__ [C:2] [TYPE Function] [SEMANTICS asgi, call, trace, seed, header]
# @ingroup Core
# @BRIEF ASGI __call__ that seeds trace_id in the root task context before forwarding.
# @BRIEF ASGI __call__ that seeds trace_id and injects X-Trace-Id response header.
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
@@ -82,7 +82,19 @@ class TraceContextMiddleware:
else:
seed_trace_id()
await self.app(scope, receive, send)
# Capture trace_id for response header injection
response_trace_id = get_trace_id()
async def send_with_trace(message: dict) -> None:
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
headers.append(
(b"x-trace-id", response_trace_id.encode("ascii"))
)
message["headers"] = headers
await send(message)
await self.app(scope, receive, send_with_trace)
# #endregion TraceContextMiddleware.__call__

View File

@@ -359,4 +359,81 @@ class TestPersistPre:
assert count == 3
# ── _check_cache() Logging Tests ──────────────────────────────────────
class TestCacheHitLogging:
"""Tests for _check_cache() logging behavior — aggregated log, no per-row logs."""
# #region test_cache_hit_logging_aggregated [C:2] [TYPE Function] [SEMANTICS test,cache,logging]
# @BRIEF When cache hits exist, a single aggregated "Translation cache hits"
# log is emitted with the correct count, NOT per-row logs.
def test_cache_hit_logging_aggregated(self):
from unittest.mock import patch
svc = _make_service()
# Build rows where each will produce a cache hit
rows = []
for i in range(3):
row = _make_row(detected_lang="ru",
source_text=f"source_{i}",
source_data={"id": str(i)})
row.pop("_source_hash", None) # let _check_cache compute it
rows.append(row)
job = MagicMock(spec=TranslationJob)
job.context_columns = None
with patch("src.plugins.translate._batch_proc._check_translation_cache",
return_value={"ru": "cached", "en": "cached"}), \
patch("src.plugins.translate._batch_proc.logger") as mock_logger:
svc._check_cache(job, rows, "dummy_hash", "dummy_cfg")
# logger.reason should be called exactly once with "Translation cache hits"
reason_calls = [
c for c in mock_logger.reason.call_args_list
if "Translation cache hits" in str(c)
]
assert len(reason_calls) == 1, (
f"Expected exactly 1 aggregated cache-hit log, got {len(reason_calls)}"
)
# Verify count: 3 rows, all cached
call = reason_calls[0]
assert call.args[0] == "Translation cache hits"
assert call.args[1]["cache_hits"] == 3
assert call.args[1]["batch_rows"] == 3
# #endregion test_cache_hit_logging_aggregated
# #region test_cache_hit_zero_no_log [C:2] [TYPE Function] [SEMANTICS test,cache,logging,zero]
# @BRIEF When no cache hits occur, no "Translation cache hits" log is emitted.
def test_cache_hit_zero_no_log(self):
from unittest.mock import patch
svc = _make_service()
rows = [_make_row(detected_lang="ru", source_text="no cache",
source_data={"id": "1"})]
rows[0].pop("_source_hash", None)
job = MagicMock(spec=TranslationJob)
job.context_columns = None
with patch("src.plugins.translate._batch_proc._check_translation_cache",
return_value=None), \
patch("src.plugins.translate._batch_proc.logger") as mock_logger:
svc._check_cache(job, rows, "dummy_hash", "dummy_cfg")
reason_calls = [
c for c in mock_logger.reason.call_args_list
if "Translation cache hits" in str(c)
]
assert len(reason_calls) == 0, (
f"Expected no cache-hit log when cache_hits=0, got {len(reason_calls)}"
)
# #endregion test_cache_hit_zero_no_log
# #endregion BatchClassifyPersistTests

View File

@@ -12,7 +12,7 @@
from datetime import UTC, datetime
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from src.models.translate import TranslationRun, TranslationSchedule
from src.plugins.translate.scheduler import TranslationScheduler
@@ -307,6 +307,7 @@ class TestExecuteScheduledTranslation:
# region test_no_notification_on_success [TYPE Function]
# @PURPOSE: On successful execution, NotificationService.send() is NOT called.
@patch("src.dependencies.get_async_job_runner")
@patch("src.plugins.translate.scheduler.NotificationService")
@patch("src.plugins.translate.orchestrator.TranslationOrchestrator")
@patch("src.plugins.translate.scheduler.seed_trace_id")
@@ -315,6 +316,7 @@ class TestExecuteScheduledTranslation:
_mock_seed_trace_id: MagicMock,
mock_orchestrator_class: MagicMock,
mock_notification_service_class: MagicMock,
mock_runner_factory: MagicMock,
) -> None:
from src.plugins.translate.scheduler import execute_scheduled_translation
@@ -322,6 +324,10 @@ class TestExecuteScheduledTranslation:
db_maker = MagicMock(return_value=db)
config_manager = MagicMock()
# Mock runner
mock_runner = MagicMock()
mock_runner_factory.return_value = mock_runner
# Mock schedule query returns active schedule
schedule = MagicMock(spec=TranslationSchedule)
schedule.id = "sched-1"
@@ -344,7 +350,7 @@ class TestExecuteScheduledTranslation:
mock_run.status = "COMPLETED"
mock_run.insert_status = "succeeded"
mock_orch_instance.start_run.return_value = MagicMock(id="run-1", status="PENDING")
mock_orch_instance.execute_run.side_effect = None
mock_orch_instance.execute_run = AsyncMock(return_value=None)
mock_orchestrator_class.return_value = mock_orch_instance
execute_scheduled_translation(

View File

@@ -146,7 +146,7 @@ class TestSQLInsertService:
mock_result.success = True
mock_result.rows_affected = 10
mock_result.execution_time_ms = 50
mock_exec_cls.return_value.execute_sql.return_value = mock_result
mock_exec_cls.return_value.execute_sql = AsyncMock(return_value=mock_result)
result = await service._execute_direct_db(job, run, "INSERT INTO ...")
@@ -177,7 +177,7 @@ class TestSQLInsertService:
mock_result.success = False
mock_result.error = "DB timeout"
mock_result.execution_time_ms = 5000
mock_exec_cls.return_value.execute_sql.return_value = mock_result
mock_exec_cls.return_value.execute_sql = AsyncMock(return_value=mock_result)
result = await service._execute_direct_db(job, run, "INSERT INTO ...")

View File

@@ -121,15 +121,17 @@ class TestTokenBudget:
# region test_small_rows_fit_at_requested_size [TYPE Function]
# @BRIEF Short text rows fill the requested batch_size without reduction.
# @RATIONALE OUTPUT_PER_ROW_PER_LANG=200 means 1 lang × 200 + 50 overhead = 250 tok/row.
# With DEFAULT_MAX_OUTPUT_TOKENS=16384 and overhead 5000, max rows ≈ 45.
def test_small_rows_fit_at_requested_size(self):
"""Short text of ~50 chars should fit 50-row batch easily."""
rows = [_make_row("Hello world, this is a short text for translation.")] * 50
"""Short text of ~50 chars should fill the maximum output-limited batch size."""
rows = [_make_row("Hello world, this is a short text for translation.")] * 45
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru"],
batch_size=50,
batch_size=45,
)
assert result["batch_size_adjusted"] == 50
assert result["batch_size_adjusted"] == 45
assert result["estimated_input_tokens"] > 0
assert result["estimated_output_tokens"] > 0
assert result["max_output_needed"] >= 4096
@@ -281,4 +283,100 @@ class TestTokenBudget:
# endregion test_no_target_languages_defaults_to_en
# endregion TestTokenBudget
# region TestProviderDefaults [TYPE Class]
# @BRIEF Test suite for PROVIDER_DEFAULTS resolution in estimate_token_budget.
class TestProviderDefaults:
# region test_qwen_flash_provider_defaults [TYPE Function]
# @BRIEF qwen-flash resolves to context_window=131072, max_output_tokens=32768.
def test_qwen_flash_provider_defaults(self):
"""qwen-flash model name triggers PROVIDER_DEFAULTS with known caps."""
rows = [_make_row("Short text.")] * 5
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru"],
provider_info="qwen-flash",
batch_size=5,
)
# qwen-flash has context_window=131072, max_output_tokens=32768
assert result["max_output_tokens"] == 32768
# available_input_budget = context_window - max_output_tokens
assert result["available_input_budget"] == 131072 - 32768
assert result["warning"] is None
# endregion test_qwen_flash_provider_defaults
# region test_qwen_flash_overridden_by_explicit [TYPE Function]
# @BRIEF Explicit context_window/max_output_tokens override provider defaults.
def test_qwen_flash_overridden_by_explicit(self):
"""When both provider_info and explicit caps given, explicit wins."""
rows = [_make_row("Short text.")] * 5
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru"],
provider_info="qwen-flash",
context_window=99999,
max_output_tokens=20000,
batch_size=5,
)
assert result["max_output_tokens"] == 20000
assert result["available_input_budget"] == 99999 - 20000
# endregion test_qwen_flash_overridden_by_explicit
# endregion TestProviderDefaults
# region TestOutputPerRow [TYPE Class]
# @BRIEF Test suite for OUTPUT_PER_ROW_PER_LANG=200 constraint in _compute_max_rows_by_output.
class TestOutputPerRow:
# region test_output_per_row_lang_200 [TYPE Function]
# @BRIEF _compute_max_rows_by_output uses OUTPUT_PER_ROW_PER_LANG=200.
def test_output_per_row_lang_200(self):
"""Verify the max rows calculation uses 200 tok/row/lang."""
from src.plugins.translate._token_budget import (
OUTPUT_PER_ROW_PER_LANG,
REASONING_OVERHEAD,
MAX_OUTPUT_HEADROOM,
OUTPUT_SAFETY_FACTOR,
)
num_languages = 1
max_output_tokens = 16384
max_rows = _compute_max_rows_by_output(
max_output_tokens=max_output_tokens,
num_languages=num_languages,
)
# Manual computation matching the formula:
# overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM = 2000 + 3000 = 5000
# per_row = 1 * 200 + 50 = 250
# available = int((16384 - 5000) * 0.55) = int(11384 * 0.55) = int(6261.2) = 6261
# max(6261 // 250, 1) = max(25, 1) = 25
overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM
per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + 50
available = int((max_output_tokens - overhead) * OUTPUT_SAFETY_FACTOR)
expected = max(available // per_row, 1)
assert max_rows == expected, (
f"max_rows={max_rows} != computed={expected}. "
f"Check that OUTPUT_PER_ROW_PER_LANG={OUTPUT_PER_ROW_PER_LANG} is used."
)
# endregion test_output_per_row_lang_200
# region test_output_per_row_lang_200_multi [TYPE Function]
# @BRIEF More languages reduce max rows proportionally (OUTPUT_PER_ROW_PER_LANG=200).
def test_output_per_row_lang_200_multi(self):
"""4 languages produce fewer max_rows than 1 language."""
single = _compute_max_rows_by_output(max_output_tokens=32768, num_languages=1)
multi = _compute_max_rows_by_output(max_output_tokens=32768, num_languages=4)
# 4 languages = 4x per-row cost → fewer rows fit
assert multi < single
assert multi >= 1
# endregion test_output_per_row_lang_200_multi
# endregion TestOutputPerRow
# #endregion TestTokenBudget

View File

@@ -1,16 +1,18 @@
# #region BatchInsertService [C:3] [TYPE Module] [SEMANTICS translate, insert, sqllab, upsert, target]
# #region BatchInsertService [C:3] [TYPE Module] [SEMANTICS translate, insert, sqllab, direct_db, upsert, target]
# @defgroup Translate Module group.
# @BRIEF Insert successful translation records into target table via Superset SQL Lab.
# @BRIEF Insert successful translation records into target table via Superset SQL Lab OR direct DB.
# Builds INSERT SQL (original + per-language rows), resolves backend dialect,
# and executes via SupersetSqlLabExecutor.
# and executes via SupersetSqlLabExecutor or DbExecutor depending on job.insert_method.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [TranslationRecord], [TranslationLanguage]
# @RELATION DEPENDS_ON -> [SQLGenerator], [SupersetSqlLabExecutor]
# @RELATION DEPENDS_ON -> [Core.DbExecutor], [Core.ConnectionService]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @PRE Batch has committed TranslationRecords with status SUCCESS. Job has target_table configured.
# @POST Per record, N+1 rows are INSERTED: 1 original + N translations.
# @SIDE_EFFECT HTTP call to Superset SQL Lab API. Writes to target database.
# @SIDE_EFFECT HTTP call to Superset SQL Lab API OR native DB driver call. Writes to target database.
# @RATIONALE Extracted from BatchProcessingService (583 lines) to comply with INV_7.
# Direct DB path added for ClickHouse and other native driver support per job.insert_method.
# @REJECTED Inline insert logic in process_batch — made the function 583 lines.
import json
@@ -18,6 +20,8 @@ import json
from sqlalchemy.orm import Session, joinedload
from ...core.config_manager import ConfigManager
from ...core.connection_service import ConnectionService
from ...core.db_executor import DbExecutor
from ...core.logger import belief_scope, logger
from ...models.translate import TranslationJob, TranslationRecord
from .sql_generator import SQLGenerator, _normalize_timestamp_value
@@ -47,7 +51,7 @@ async def insert_batch_to_target(
columns = [effective_target or "translated_text"]
rows_for_sql = [{columns[0]: rec.target_sql or ""} for rec in records]
dialect, executor = await _resolve_insert_backend(config_manager, job, batch_id)
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, batch_id)
if dialect is None:
return
@@ -66,7 +70,7 @@ async def insert_batch_to_target(
for sql, chunk_count in statements:
if sql is None:
continue
await _execute_insert_sql(executor, sql, batch_id, chunk_count)
await _execute_insert_sql(executor, sql, batch_id, chunk_count, connection_id=connection_id)
total_inserted += chunk_count
logger.reason(f"Batch {batch_id[:12]} inserted {total_inserted} rows",
{"batch_id": batch_id, "rows": total_inserted, "chunks": len(statements)})
@@ -194,11 +198,46 @@ def _build_insert_rows(
# #endregion _build_insert_rows
# #region _resolve_insert_backend [C:1] [TYPE Function]
# #region _resolve_insert_backend [C:2] [TYPE Function] [SEMANTICS translate, insert, backend, dispatch]
# @ingroup Translate
# @BRIEF Resolve the database backend and SQL executor for batch insert.
# Checks job.insert_method — direct_db routes to DbExecutor, otherwise SupersetSqlLabExecutor.
# @PRE config_manager is valid. job has target_table configured.
# @POST Returns (dialect, executor, connection_id). connection_id is None for sqllab path.
# @RELATION DEPENDS_ON -> [Core.ConnectionService]
# @RELATION DEPENDS_ON -> [Core.DbExecutor]
# @RELATION DEPENDS_ON -> [SupersetSqlLabExecutor]
# @RATIONALE Direct DB path added for ClickHouse and other native driver support.
# Previously always used Superset SQL Lab, ignoring job.insert_method.
async def _resolve_insert_backend(
config_manager: ConfigManager, job: TranslationJob, batch_id: str,
) -> tuple[str | None, SupersetSqlLabExecutor | None]:
"""Resolve the database backend and SQL executor for batch insert."""
) -> tuple[str | None, SupersetSqlLabExecutor | DbExecutor | None, str | None]:
"""Resolve the database backend and SQL executor for batch insert.
Returns:
Tuple of (dialect, executor, connection_id):
- direct_db: dialect from connection, DbExecutor, job.connection_id
- sqllab: dialect from Superset backend, SupersetSqlLabExecutor, None
- failure: None, None, None (logging already emitted)
"""
# ── Direct DB path ──
if getattr(job, "insert_method", None) == "direct_db":
if not job.connection_id:
raise ValueError(
f"Batch {batch_id}: insert_method=direct_db but connection_id is missing"
)
conn_service = ConnectionService(config_manager)
conn = conn_service.get_connection(job.connection_id)
if not conn:
raise ValueError(
f"Batch {batch_id}: connection_id='{job.connection_id}' not found in global settings"
)
logger.reason("Batch insert using direct DB connection",
{"batch_id": batch_id, "dialect": conn.dialect, "connection": conn.name})
executor: SupersetSqlLabExecutor | DbExecutor = DbExecutor(conn_service)
return conn.dialect, executor, job.connection_id
# ── Superset SQL Lab path (default) ──
try:
env_id = job.environment_id or job.source_dialect or ""
executor = SupersetSqlLabExecutor(config_manager, env_id)
@@ -207,9 +246,9 @@ async def _resolve_insert_backend(
except Exception as e:
logger.explore("Failed to resolve database backend for batch insert",
{"batch_id": batch_id, "error": str(e)})
return None, None
return None, None, None
dialect = real_backend or job.database_dialect or job.target_dialect or "postgresql"
return dialect, executor
return dialect, executor, None
# #endregion _resolve_insert_backend
@@ -234,15 +273,35 @@ def _generate_insert_sql(
# #endregion _generate_insert_sql
# #region _execute_insert_sql [C:1] [TYPE Function]
async def _execute_insert_sql(executor: SupersetSqlLabExecutor, sql: str, batch_id: str, row_count: int) -> None:
"""Execute the INSERT SQL via Superset SQL Lab."""
# #region _execute_insert_sql [C:2] [TYPE Function] [SEMANTICS translate, insert, execute, dispatch]
# @ingroup Translate
# @BRIEF Execute INSERT SQL via either Superset SQL Lab or direct DB (DbExecutor).
# @PRE executor is a valid initialized executor. sql is dialect-appropriate.
# @POST SQL executed. Status logged (non-fatal — batch processing continues).
# @SIDE_EFFECT Writes to target DB or calls Superset API.
# @RATIONALE Branch on executor type: DbExecutor uses execute_sql(connection_id),
# SupersetSqlLabExecutor uses execute_and_poll(). connection_id discriminates the path.
async def _execute_insert_sql(
executor: SupersetSqlLabExecutor | DbExecutor, sql: str,
batch_id: str, row_count: int, connection_id: str | None = None,
) -> None:
"""Execute the INSERT SQL via Superset SQL Lab or direct DB."""
try:
result = await executor.execute_and_poll(sql=sql, max_polls=30, poll_interval_seconds=2.0)
if isinstance(executor, DbExecutor):
result = await executor.execute_sql(connection_id, sql) # type: ignore[arg-type]
status = "success" if result.success else "failed"
if not result.success:
logger.explore("Direct DB insert failed for batch",
{"batch_id": batch_id, "error": result.error},
error=result.error or "Unknown DB error")
else:
# Superset SQL Lab path
result = await executor.execute_and_poll(sql=sql, max_polls=30, poll_interval_seconds=2.0)
status = result.get("status", "unknown")
except Exception as e:
logger.explore("Superset SQL submission failed for batch", {"batch_id": batch_id, "error": str(e)})
logger.explore("SQL submission failed for batch", {"batch_id": batch_id, "error": str(e)})
return
logger.reason(f"Chunk inserted for batch {batch_id[:12]}",
{"batch_id": batch_id, "rows": row_count, "status": result.get("status")})
{"batch_id": batch_id, "rows": row_count, "status": status})
# #endregion _execute_insert_sql
# #endregion BatchInsertService

View File

@@ -102,6 +102,7 @@ class BatchProcessingService:
return b
def _check_cache(self, job, batch_rows, dict_snapshot_hash, config_hash):
cache_hits = 0
for row in batch_rows:
if row.get("approved_translation"):
continue
@@ -114,7 +115,12 @@ class BatchProcessingService:
cached = _check_translation_cache(self.db, h)
if cached:
row["_cached_lang_values"] = cached
logger.reason("Translation cache hit", {"source_hash": h[:12], "langs": list(cached.keys())})
cache_hits += 1
if cache_hits > 0:
logger.reason(
"Translation cache hits",
{"batch_rows": len(batch_rows), "cache_hits": cache_hits},
)
# ★ Local language detection — replaces LLM-based detection
def _detect_languages(self, batch_rows: list[dict], target_languages: list[str]) -> None:

View File

@@ -44,15 +44,21 @@ PROVIDER_DEFAULTS: dict[str, dict[str, int]] = {
"o1-mini": {"context_window": 128000, "max_output_tokens": 65536},
"claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192},
"deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192},
"qwen-flash": {"context_window": 131072, "max_output_tokens": 32768},
"qwen-plus": {"context_window": 131072, "max_output_tokens": 32768},
"qwen-max": {"context_window": 32768, "max_output_tokens": 8192},
"qwen-coder": {"context_window": 131072, "max_output_tokens": 32768},
"default": {"context_window": 64000, "max_output_tokens": 16384},
}
# #endregion PROVIDER_DEFAULTS
# Increased from 60 to 120 because SQL/dashboard text and JSON structure need more.
# #region OUTPUT_PER_ROW_PER_LANG [TYPE Constant]
# @ingroup Translate
# Increased from 60 to 120 because SQL/dashboard text and JSON structure need more.
OUTPUT_PER_ROW_PER_LANG = 120
# Increased from 120 to 200 because production logs show finish_reason=length
# even with 120 tok/row/lang — long source texts produce long translations.
# With 3 target languages, 200 tok/row/lang × 36 rows = 21600 output tokens
# + JSON overhead + reasoning, well within 24K-32K typical output budgets.
OUTPUT_PER_ROW_PER_LANG = 200
# #endregion OUTPUT_PER_ROW_PER_LANG
# #region JSON_OVERHEAD_PER_ROW [TYPE Constant]
# @ingroup Translate
@@ -109,7 +115,7 @@ INPUT_SAFETY_FACTOR = 0.75
# #region OUTPUT_SAFETY_FACTOR [TYPE Constant]
# @ingroup Translate
# @BRIEF Multiplier applied to output budget when computing max rows per batch.
OUTPUT_SAFETY_FACTOR = 0.70
OUTPUT_SAFETY_FACTOR = 0.55
# #endregion OUTPUT_SAFETY_FACTOR
# #region MIN_MAX_TOKENS [TYPE Constant]

View File

@@ -7,7 +7,6 @@
# @POST Metrics are aggregated and returned; no side effects.
# @RATIONALE Live events (<90 days) + MetricSnapshot (>=90 days) fusion for complete picture.
# @REJECTED Querying all events for all time — would be slow; MetricSnapshot provides historical summary.
# @COMPLEXITY 4
from typing import Any
@@ -31,7 +30,7 @@ class TranslationMetrics:
def __init__(self, db: Session):
self.db = db
#region get_job_metrics [TYPE Function]
#region get_job_metrics [C:3] [TYPE Function]
# @BRIEF Get aggregated metrics for a specific job.
# @PRE job_id exists.
# @POST Returns dict with metrics from events + latest snapshot.
@@ -176,8 +175,8 @@ class TranslationMetrics:
}
# endregion get_job_metrics
# region get_all_metrics [TYPE Function]
# @PURPOSE Get aggregated metrics for all jobs.
# region get_all_metrics [C:2] [TYPE Function]
# @BRIEF Get aggregated metrics for all jobs by iterating get_job_metrics.
# @POST Returns list of per-job metrics.
def get_all_metrics(self) -> list[dict[str, Any]]:
with belief_scope("TranslationMetrics.get_all_metrics"):

View File

@@ -27,6 +27,30 @@ from ...services.notifications.service import NotificationService
from .events import TranslationEventLog
# #region _ensure_aware [C:2] [TYPE Function] [SEMANTICS datetime,normalize,utc]
# @ingroup Translate
# @BRIEF Convert naive DB datetime to UTC-aware for safe arithmetic.
# @PRE dt is either None or a datetime (naive or aware).
# @POST Returns None if input is None; otherwise returns UTC-aware datetime.
# @RATIONALE SQLAlchemy DateTime (without timezone=True) returns naive datetimes
# even when stored as UTC. Subtracting naive from datetime.now(UTC)
# raises TypeError. This normalises naive→aware assuming UTC storage.
# @SIDE_EFFECT Pure function — no I/O or state mutation.
def _ensure_aware(dt: datetime | None) -> datetime | None:
"""Convert naive DB datetime to UTC-aware for safe arithmetic.
SQLAlchemy DateTime (without timezone=True) returns naive datetimes
even when stored as UTC. This normalises them to aware datetimes
so subtraction with datetime.now(UTC) does not raise TypeError.
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
# #endregion _ensure_aware
# #region TranslationScheduler [C:4] [TYPE Class]
# @defgroup Translate Module group.
# @BRIEF CRUD for TranslationSchedule rows + APScheduler registration wrappers.
@@ -250,7 +274,6 @@ class TranslationScheduler:
# @POST Translation run created and executed if no concurrent run exists.
# @SIDE_EFFECT DB writes; LLM calls; Superset API calls.
# @RATIONALE New-key-only mode compares keys against most recent succeeded run; baseline_expired fallback does full.
# @COMPLEXITY 4
def execute_scheduled_translation(
schedule_id: str,
@@ -294,22 +317,29 @@ def execute_scheduled_translation(
.first()
)
if active_run:
run_created = _ensure_aware(active_run.created_at)
is_stale = (
active_run.status == "PENDING"
and active_run.created_at
and (now - active_run.created_at) > stale_threshold
and run_created is not None
and (now - run_created) > stale_threshold
)
if is_stale:
# Mark ALL stale PENDING runs as FAILED
stale_threshold_dt = now - stale_threshold
stale_runs = (
db.query(TranslationRun)
.filter(
TranslationRun.job_id == job_id,
TranslationRun.status == "PENDING",
TranslationRun.created_at < (now - stale_threshold),
)
.all()
)
# Filter in Python to avoid DB type mismatch on DateTime comparison
stale_runs = [
sr for sr in stale_runs
if _ensure_aware(sr.created_at) is not None
and _ensure_aware(sr.created_at) < stale_threshold_dt
]
for sr in stale_runs:
sr.status = "FAILED"
sr.error_message = "Stale: concurrency deadlock — marked by scheduled trigger"
@@ -345,7 +375,11 @@ def execute_scheduled_translation(
.first()
)
if most_recent:
age = datetime.now(UTC) - most_recent.created_at
recent_created = _ensure_aware(most_recent.created_at)
if recent_created is not None:
age = datetime.now(UTC) - recent_created
else:
age = timedelta(0)
if age > timedelta(days=90):
baseline_expired = True
logger.reason("Baseline expired — full translation", {

View File

@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING
from cryptography.exceptions import InvalidTag
from sqlalchemy.orm import Session
from ..core.encryption import EncryptionManager
from ..core.encryption import get_encryption_manager
from ..core.logger import belief_scope, logger
from ..models.llm import LLMProvider
@@ -72,7 +72,7 @@ class LLMProviderService:
# @RELATION DEPENDS_ON ->[EncryptionManager]
def __init__(self, db: Session):
self.db = db
self.encryption = EncryptionManager()
self.encryption = get_encryption_manager()
# endregion LLMProviderService_init

View File

@@ -168,4 +168,50 @@ class TestEnsureEncryptionKey:
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
ensure_encryption_key(env_file)
# #endregion test_ensure_key_file_without_key_raises
# ── get_encryption_manager singleton ────────────────────────────────────────
class TestGetEncryptionManager:
"""get_encryption_manager() — process-wide singleton."""
# #region test_singleton_returns_same_instance [C:2] [TYPE Function] [SEMANTICS test,singleton]
# @BRIEF Two calls to get_encryption_manager return the identical object.
def test_singleton_returns_same_instance(self, monkeypatch):
from src.core.encryption import get_encryption_manager
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
# Reset module-level singleton
import src.core.encryption as enc_mod
enc_mod._encryption_manager = None
mgr1 = get_encryption_manager()
mgr2 = get_encryption_manager()
assert mgr1 is mgr2
# #endregion test_singleton_returns_same_instance
# #region test_singleton_after_exception [C:2] [TYPE Function] [SEMANTICS test,singleton,recovery]
# @BRIEF If first creation fails (no ENCRYPTION_KEY), singleton resets
# properly — subsequent call with valid key succeeds and is cached.
def test_singleton_after_exception(self, monkeypatch):
from src.core.encryption import get_encryption_manager
# Ensure no key — first call raises
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
import src.core.encryption as enc_mod
enc_mod._encryption_manager = None
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
get_encryption_manager()
# Singleton remains None after exception
assert enc_mod._encryption_manager is None
# Now set valid key — second call succeeds
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = get_encryption_manager()
assert mgr is not None
# And subsequent call returns same instance
assert get_encryption_manager() is mgr
# #endregion test_singleton_after_exception
# #endregion Test.Encryption

View File

@@ -400,6 +400,28 @@ class TestExecuteInsertSql:
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5)
executor.execute_and_poll.assert_called_once()
@pytest.mark.asyncio
async def test_direct_db_success(self):
"""DbExecutor path: successful execution."""
from src.core.db_executor import DbExecutor, DbExecutionResult
executor = MagicMock(spec=DbExecutor)
executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
success=True, rows_affected=5, execution_time_ms=12.0
))
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")
@pytest.mark.asyncio
async def test_direct_db_failure(self):
"""DbExecutor path: failed execution with error."""
from src.core.db_executor import DbExecutor, DbExecutionResult
executor = MagicMock(spec=DbExecutor)
executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
success=False, error="Connection refused"
))
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")
class TestResolveInsertBackend:
"""_resolve_insert_backend — resolve backend dialect and executor."""
@@ -421,13 +443,14 @@ class TestResolveInsertBackend:
mock_exec.resolve_database_id = AsyncMock()
mock_exec_cls.return_value = mock_exec
dialect, executor = await _resolve_insert_backend(config_manager, job, "batch-1")
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
assert dialect == "postgresql"
assert executor is not None
assert connection_id is None # sqllab path has no connection_id
@pytest.mark.asyncio
async def test_failure_returns_none(self):
"""Exception in resolve returns (None, None)."""
"""Exception in resolve returns (None, None, None)."""
config_manager = MagicMock()
job = MagicMock(spec=TranslationJob)
job.environment_id = None
@@ -435,9 +458,10 @@ class TestResolveInsertBackend:
with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor",
side_effect=Exception("Failed")):
dialect, executor = await _resolve_insert_backend(config_manager, job, "batch-1")
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
assert dialect is None
assert executor is None
assert connection_id is None
@pytest.mark.asyncio
async def test_fallback_dialect(self):
@@ -456,8 +480,59 @@ class TestResolveInsertBackend:
mock_exec.resolve_database_id = AsyncMock()
mock_exec_cls.return_value = mock_exec
dialect, executor = await _resolve_insert_backend(config_manager, job, "batch-1")
dialect, _, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
assert dialect == "mysql"
assert connection_id is None
@pytest.mark.asyncio
async def test_direct_db_path(self):
"""direct_db path returns DbExecutor with connection_id."""
config_manager = MagicMock()
job = MagicMock(spec=TranslationJob)
job.insert_method = "direct_db"
job.connection_id = "clickhouse-conn-1"
with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
mock_cs = MagicMock()
mock_conn = MagicMock()
mock_conn.dialect = "clickhouse"
mock_conn.name = "Test ClickHouse"
mock_cs.get_connection.return_value = mock_conn
mock_cs_cls.return_value = mock_cs
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
assert dialect == "clickhouse"
assert connection_id == "clickhouse-conn-1"
# Should return DbExecutor, not SupersetSqlLabExecutor
from src.core.db_executor import DbExecutor
assert isinstance(executor, DbExecutor)
@pytest.mark.asyncio
async def test_direct_db_missing_conn_raises(self):
"""When direct_db connection not found, raises ValueError (no fallback)."""
config_manager = MagicMock()
job = MagicMock(spec=TranslationJob)
job.insert_method = "direct_db"
job.connection_id = "missing-conn"
with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
mock_cs = MagicMock()
mock_cs.get_connection.return_value = None
mock_cs_cls.return_value = mock_cs
with pytest.raises(ValueError, match="connection_id='missing-conn' not found"):
await _resolve_insert_backend(config_manager, job, "batch-1")
@pytest.mark.asyncio
async def test_direct_db_no_connection_id_raises(self):
"""When direct_db is set but connection_id is None, raises ValueError (no fallback)."""
config_manager = MagicMock()
job = MagicMock(spec=TranslationJob)
job.insert_method = "direct_db"
job.connection_id = None
with pytest.raises(ValueError, match="connection_id is missing"):
await _resolve_insert_backend(config_manager, job, "batch-1")
class TestInsertBatchToTarget:
@@ -499,7 +574,7 @@ class TestInsertBatchToTarget:
with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[rec]):
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
return_value=("postgresql", AsyncMock())):
return_value=("postgresql", AsyncMock(), None)):
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
return_value=[("INSERT INTO ...", 1)]):
with patch("src.plugins.translate._batch_insert._execute_insert_sql",
@@ -530,7 +605,7 @@ class TestInsertBatchToTarget:
with patch("src.plugins.translate._batch_insert._build_insert_rows",
return_value=[{"text": "bonjour"}]):
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
return_value=(None, None)):
return_value=(None, None, None)):
result = await insert_batch_to_target(
db_session, config_manager, job, "batch-1", "run-1"
)

View File

@@ -163,7 +163,7 @@ class TestInsertBatchToTargetEdgeCases:
with patch("src.plugins.translate._batch_insert._build_insert_rows",
return_value=[{"text": "bonjour"}]):
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
return_value=("postgresql", AsyncMock())):
return_value=("postgresql", AsyncMock(), None)):
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
return_value=[("INSERT INTO ...", 1)]):
with patch("src.plugins.translate._batch_insert._execute_insert_sql",
@@ -208,7 +208,7 @@ class TestInsertBatchToTargetEdgeCases:
with patch("src.plugins.translate._batch_insert._build_insert_rows",
return_value=[{"text": "bonjour", "is_original": 1}]):
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
return_value=("postgresql", mock_executor)):
return_value=("postgresql", mock_executor, None)):
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
return_value=[(None, 0), ("INSERT INTO target ...", 1)]):
result = await insert_batch_to_target(

View File

@@ -257,4 +257,61 @@ class TestDeleteEdgeCases:
svc = ConnectionService(mock_cm)
result = svc.update_connection("c1", {"name": "My PG"})
assert result["name"] == "My PG"
class TestConnectionTimeout:
"""Connection test timeout behavior — asyncio.TimeoutError handling."""
# #region test_timeout_error_response [C:2] [TYPE Function] [SEMANTICS test,connection,timeout,error]
# @BRIEF When _test_postgresql hangs, asyncio.TimeoutError is caught and
# returns {success: false, error: "Connection test timed out after 15s"}.
@pytest.mark.asyncio
async def test_timeout_error_response(self, mock_cm):
from src.core.config_models import DatabaseConnection
conn = DatabaseConnection(id="c1", name="PG", host="h", port=5432,
database="d", username="u", password="p",
dialect="postgresql")
svc = ConnectionService(mock_cm)
svc.TEST_CONNECTION_TIMEOUT_SEC = 0.1 # fast timeout for test
async def _hang(*_):
import asyncio
await asyncio.sleep(3600) # never returns
with patch.object(svc, 'get_connection', return_value=conn), \
patch.object(svc, '_test_postgresql', side_effect=_hang):
result = await svc.test_connection("c1")
assert result["success"] is False
assert "Connection test timed out" in result["error"]
assert "15" not in result["error"] # we overrode to 0.1
assert "after" in result["error"]
# #endregion test_timeout_error_response
# #region test_timeout_still_records_latency [C:2] [TYPE Function] [SEMANTICS test,connection,timeout,latency]
# @BRIEF Even on timeout, latency_ms field is populated with elapsed time.
@pytest.mark.asyncio
async def test_timeout_still_records_latency(self, mock_cm):
from src.core.config_models import DatabaseConnection
conn = DatabaseConnection(id="c1", name="PG", host="h", port=5432,
database="d", username="u", password="p",
dialect="postgresql")
svc = ConnectionService(mock_cm)
svc.TEST_CONNECTION_TIMEOUT_SEC = 0.05 # very fast timeout
async def _hang(*_):
import asyncio
await asyncio.sleep(3600)
with patch.object(svc, 'get_connection', return_value=conn), \
patch.object(svc, '_test_postgresql', side_effect=_hang):
result = await svc.test_connection("c1")
assert "latency_ms" in result
assert isinstance(result["latency_ms"], int)
assert result["latency_ms"] >= 0 # should be a non-negative elapsed time
# #endregion test_timeout_still_records_latency
# #endregion Test.Core.ConnectionService.Edge

View File

@@ -7,8 +7,8 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
# #region test_trace_middleware [C:2] [TYPE Function]
@@ -93,12 +93,155 @@ class TestTraceContextMiddleware:
@pytest.mark.asyncio
async def test_forwards_to_app(self, middleware, mock_app):
"""After seeding, the ASGI app is called."""
"""After seeding, the ASGI app is called with scope, receive, and a wrapped send."""
scope = {"type": "http", "headers": []}
receive = MagicMock()
send = MagicMock()
await middleware(scope, receive, send)
mock_app.assert_called_once_with(scope, receive, send)
# The middleware wraps send in send_with_trace, so verify scope+receive match
mock_app.assert_called_once()
call_args = mock_app.call_args
assert call_args[0][0] == scope
assert call_args[0][1] == receive
# call_args[0][2] is send_with_trace — a wrapper function, not the original send
assert callable(call_args[0][2])
# #endregion test_trace_middleware
# #region TestTraceResponseHeaders [C:2] [TYPE Class] [SEMANTICS test,middleware,trace,header]
# @BRIEF Verify X-Trace-Id response header injection — new, preserved, passthrough.
class TestTraceResponseHeaders:
"""TraceContextMiddleware response header injection."""
@pytest.fixture
def mock_app(self):
return AsyncMock()
@pytest.fixture
def middleware(self, mock_app):
from src.core.middleware.trace import TraceContextMiddleware
return TraceContextMiddleware(mock_app)
# #region test_sets_x_trace_id_header_on_response [C:2] [TYPE Function] [SEMANTICS test,trace,header]
# @BRIEF HTTP response receives x-trace-id header with a valid UUID4 value.
@pytest.mark.asyncio
async def test_sets_x_trace_id_header_on_response(self, middleware, mock_app):
"""The middleware injects x-trace-id into http.response.start headers."""
import uuid
scope = {"type": "http", "headers": []}
receive = MagicMock()
send = AsyncMock()
# The inner app receives send_with_trace as its 3rd positional arg.
# Use that wrapped send to exercise header injection.
async def _inner_app(*args):
_send = args[2] # send_with_trace from middleware
await _send({
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/plain")],
})
await _send({
"type": "http.response.body",
"body": b"OK",
})
mock_app.side_effect = _inner_app
await middleware(scope, receive, send)
# Collect send calls — find http.response.start
send_calls = [c for c in send.call_args_list
if c.args[0]["type"] == "http.response.start"]
assert len(send_calls) >= 1, "No http.response.start send call"
headers = dict(send_calls[0].args[0].get("headers", []))
assert b"x-trace-id" in headers, "Missing x-trace-id header"
trace_id = headers[b"x-trace-id"].decode("ascii")
# Valid UUID4
parsed = uuid.UUID(hex=trace_id)
assert parsed.version == 4, f"Trace ID is not UUID4: got version {parsed.version}"
# #endregion test_sets_x_trace_id_header_on_response
# #region test_preserves_incoming_trace_id [C:2] [TYPE Function] [SEMANTICS test,trace,header,preserve]
# @BRIEF When request carries X-Trace-ID, the response header uses the same value.
@pytest.mark.asyncio
async def test_preserves_incoming_trace_id(self, middleware, mock_app):
"""Incoming X-Trace-ID is echoed back in the response header."""
incoming_id = "550e8400-e29b-41d4-a716-446655440000"
scope = {
"type": "http",
"headers": [(b"x-trace-id", incoming_id.encode())],
}
receive = MagicMock()
send = AsyncMock()
async def _inner_app(*args):
_send = args[2] # send_with_trace from middleware
await _send({
"type": "http.response.start",
"status": 200,
"headers": [],
})
await _send({
"type": "http.response.body",
"body": b"OK",
})
mock_app.side_effect = _inner_app
await middleware(scope, receive, send)
send_calls = [c for c in send.call_args_list
if c.args[0]["type"] == "http.response.start"]
assert len(send_calls) >= 1
headers = dict(send_calls[0].args[0].get("headers", []))
assert b"x-trace-id" in headers
response_trace_id = headers[b"x-trace-id"].decode("ascii")
assert response_trace_id == incoming_id, (
f"Response trace ID {response_trace_id} != incoming {incoming_id}"
)
# #endregion test_preserves_incoming_trace_id
# #region test_non_http_scope_passthrough [C:2] [TYPE Function] [SEMANTICS test,trace,websocket,passthrough]
# @BRIEF Non-HTTP scopes (websocket, lifespan) do not inject X-Trace-Id header.
@pytest.mark.asyncio
async def test_non_http_scope_passthrough(self, middleware, mock_app):
"""Websocket scope passes through without header injection."""
scope = {"type": "websocket", "headers": []}
receive = MagicMock()
send = MagicMock()
async def _inner_app(scope, receive, send):
# Ensure the app was called with the original send, not send_with_trace
pass
mock_app.side_effect = _inner_app
await middleware(scope, receive, send)
# For non-http, the middleware calls self.app(scope, receive, send)
# (the original send — no wrapper). Verify original send was called.
mock_app.assert_called_once_with(scope, receive, send)
@pytest.mark.asyncio
async def test_lifespan_scope_passthrough(self, middleware, mock_app):
"""Lifespan scope passes through without header injection."""
scope = {"type": "lifespan", "headers": []}
receive = MagicMock()
send = MagicMock()
await middleware(scope, receive, send)
# The middleware returns early for non-http, calling self.app directly
mock_app.assert_called_once_with(scope, receive, send)
# #endregion test_non_http_scope_passthrough
# #endregion TestTraceResponseHeaders
# #endregion Test.TraceContextMiddleware

View File

@@ -0,0 +1,80 @@
# #region Test.Scheduler.EnsureAware [C:2] [TYPE Module] [SEMANTICS test,scheduler,datetime,utc]
# @BRIEF Verify _ensure_aware contract — naive→UTC conversion, aware passthrough, None passthrough.
# @RELATION BINDS_TO -> [TranslationScheduler._ensure_aware]
# @TEST_EDGE naive_to_aware -> Naive datetime gets UTC tzinfo
# @TEST_EDGE already_aware -> Aware datetime returned unchanged
# @TEST_EDGE none_passthrough -> None returns None
# @TEST_EDGE subtraction_works -> Conversion enables subtraction with datetime.now(UTC)
#
# @RATIONALE SQLAlchemy DateTime (without timezone=True) returns naive datetimes
# even when stored as UTC. This module verifies the normalization
# helper used throughout execute_scheduled_translation for safe
# datetime arithmetic.
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from datetime import UTC, datetime, timedelta
# #region TestEnsureAware [C:2] [TYPE Class] [SEMANTICS test,scheduler,datetime]
# @BRIEF Tests for _ensure_aware() — naive-to-UTC conversion and edge cases.
class TestEnsureAware:
"""_ensure_aware — convert naive DB datetime to UTC-aware for safe arithmetic."""
# #region test_naive_to_aware [C:2] [TYPE Function] [SEMANTICS test,datetime,naive]
# @BRIEF Naive datetime gets UTC tzinfo attached.
def test_naive_to_aware(self):
from src.plugins.translate.scheduler import _ensure_aware
naive = datetime(2024, 6, 15, 10, 30, 0)
result = _ensure_aware(naive)
assert result.tzinfo is not None
assert result.tzinfo == UTC
assert result == naive.replace(tzinfo=UTC)
assert result.hour == 10 # no offset shift
# #endregion test_naive_to_aware
# #region test_already_aware_passthrough [C:2] [TYPE Function] [SEMANTICS test,datetime,aware]
# @BRIEF Already UTC-aware datetime returned unchanged (same object).
def test_already_aware_passthrough(self):
from src.plugins.translate.scheduler import _ensure_aware
aware = datetime(2024, 6, 15, 10, 30, 0, tzinfo=UTC)
result = _ensure_aware(aware)
assert result is aware # same object identity
assert result.tzinfo == UTC
# #endregion test_already_aware_passthrough
# #region test_none_returns_none [C:2] [TYPE Function] [SEMANTICS test,datetime,none]
# @BRIEF None input returns None.
def test_none_returns_none(self):
from src.plugins.translate.scheduler import _ensure_aware
result = _ensure_aware(None)
assert result is None
# #endregion test_none_returns_none
# #region test_subtraction_works [C:2] [TYPE Function] [SEMANTICS test,datetime,arithmetic]
# @BRIEF Naive→aware conversion enables subtraction with datetime.now(UTC)
# without TypeError (the original problem this function solves).
def test_subtraction_works(self):
from src.plugins.translate.scheduler import _ensure_aware
naive = datetime(2024, 6, 15, 10, 0, 0)
aware = _ensure_aware(naive)
now = datetime.now(UTC)
# This would raise TypeError without _ensure_aware:
# TypeError: can't subtract offset-naive and offset-aware datetimes
elapsed = now - aware
assert isinstance(elapsed, timedelta)
assert elapsed.total_seconds() > 0 # always positive vs past date
# #endregion test_subtraction_works
# #endregion TestEnsureAware
# #endregion Test.Scheduler.EnsureAware