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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user