md2link

Universal Response Localization — Architecture & Implementation

DraftJun 4, 2026

Universal Response Localization — Architecture & Implementation

Based on: backend-dynamic-data-localization-plan.md

Issue: LLM translate chậm → 504 timeout khi gọi đồng bộ trong request-response cycle

Solution: Cache Priming hybrid — pre-translate tại analysis completion time (zero user-facing latency) + sync fallback nếu cache miss + timeout fallback về English nếu LLM lỗi.

Key insight: Analysis pipeline chạy ARQ worker mất 2-5 phút. Translation chỉ mất 2-5s. Pre-translate ngay khi analysis xong → cache luôn có sẵn trước khi user mở page.


1. Kiến trúc tổng thể

1.1 Happy path — Cache đã có sẵn (99% requests)

sequenceDiagram
    participant PIPELINE as Analysis Pipeline<br/>(ARQ Worker)
    participant CACHE as Redis Cache
    participant USER as User
    participant FE as Frontend
    participant API as FastAPI

    Note over PIPELINE: Analysis chạy 2-5 phút

    PIPELINE->>PIPELINE: Analysis result ready<br/>(English, canonical)

    PAR Pre-translation
        PIPELINE->>PIPELINE: Collect candidates (step 1-3)
        PIPELINE->>PIPELINE: LLM translate gpt-4o-mini<br/>(~2-5s)
        PIPELINE->>PIPELINE: Apply + validate
        PIPELINE->>CACHE: SET cache key<br/>localization:{id}:{locale}<br/>TTL=3600s
    end

    Note over USER: Vài phút sau, user mở analysis

    USER->>FE: Click "View Analysis"<br/>locale=ja
    FE->>API: GET /api/v1/analysis/{id}<br/>X-Scopelytics-Locale: ja
    API->>CACHE: Cache lookup
    CACHE-->>API: HIT 🚀
    API-->>FE: HTTP 200<br/>(translated, 10ms)

1.2 Edge case — Cache miss (user mở quá nhanh hoặc locale chưa primed)

sequenceDiagram
    participant FE as Frontend
    participant API as FastAPI
    participant GUARD as Concurrency Guard
    participant LLM as OpenAI<br/>gpt-4o-mini
    participant CACHE as Redis Cache
    participant ARQ as ARQ Worker (retry)

    FE->>API: GET /analysis/{id}<br/>locale=ja
    API->>CACHE: Cache lookup
    CACHE-->>API: MISS

    API->>GUARD: Acquire semaphore<br/>(wait max 5s)

    alt Semaphore timeout
        API-->>FE: HTTP 200 English<br/>+ X-Localization-Status: skip_busy
        API->>ARQ: enqueue_job("localize_retry", id, ja)
        Note over ARQ: Translate background,<br/>lần sau cache hit

    else Guard acquired
        API->>LLM: chat_completions_parse()<br/>asyncio.wait_for(30s)

        alt LLM success
            LLM-->>API: decisions[]
            API->>API: Apply + validate
            API->>CACHE: SET cache key
            API-->>FE: HTTP 200<br/>(translated, ~3-6s)
        else LLM timeout / error
            LLM-->>API: ❌
            API-->>FE: HTTP 200 English<br/>+ X-Localization-Status: timeout
            API->>ARQ: enqueue_job("localize_retry", id, ja)
            Note over ARQ: Translate background,<br/>lần sau cache hit
        end
    end

1.3 So sánh các approaches

Tiêu chí Sync thuần (plan cũ) Async background (polling) Cache Priming hybrid 🏆
UX lần đầu 3-6s (chờ LLM) English ngay, flash sau 10ms 🚀 (cache hit)
UX edge case 3-6s English + chờ polling 3-6s (sync fallback)
Sửa FE Không Cần loading/polling Không
Sửa pipeline Không Không 1 ARQ job sau analysis
504 timeout Có (nếu không tune) Không Không (timeout fallback)
Cache hit rate Thấp (chờ user đầu) Thấp Cao (priming chủ động)
Phức tạp Thấp Cao Trung bình

2. Chi tiết luồng xử lý

2.1 Pipeline steps — Request path (response_localizer)

flowchart TD
    REQ["Request vào FastAPI endpoint"]

    REQ --> ENGLISH["Build English response<br/>(Pydantic model)"]

    ENGLISH --> S0{"Step 0 - Early exit<br/>locale=en hoặc feature off?"}

    S0 -->|Có| EN_OUT["Return English<br/>0 token, 0 latency"]

    S0 -->|Không| CACHE{"Cache lookup<br/>localization:{surface}:{locale}:<br/>{sha256(payload)}"}

    CACHE -->|Hit| VALID{"Pydantic validate<br/>cached payload"}
    VALID -->|Pass| HIT["Return localized<br/>🚀 10-50ms"]
    VALID -->|Fail| STEP1

    CACHE -->|Miss| STEP1["Step 1 - Serialize<br/>model → JSON dict"]
    STEP1 --> STEP2["Step 2 - Collect candidates<br/>String leaf extraction"]
    STEP2 --> STEP3["Step 3 - Policy filter<br/>Denylist + surface allowlist"]

    STEP3 --> STEP4["Step 4 - Build LLM request"]

    STEP4 --> GUARD{"Acquire semaphore<br/>wait=5s"}

    GUARD -->|Timeout| GBUSY["Return English<br/>+ enqueue ARQ retry<br/>+ log guard_busy"]

    GUARD -->|OK| LLM_CALL["Step 5 - LLM translate<br/>asyncio.wait_for(30s)<br/>model=gpt-4o-mini"]

    LLM_CALL -->|Success| APPLY["Step 6 - Apply decisions<br/>Patch theo path"]
    LLM_CALL -->|Timeout| TOUT["Return English<br/>+ enqueue ARQ retry<br/>+ log llm_timeout"]
    LLM_CALL -->|Error| ERR["Return English<br/>+ enqueue ARQ retry<br/>+ log llm_error"]

    APPLY --> VALIDATE["Step 7 - Pydantic validate"]
    VALIDATE -->|Pass| WRITE["Write cache<br/>best-effort"]
    VALIDATE -->|Fail| VFAIL["Return English<br/>+ log validate_fail"]

    WRITE --> LOCAL_OUT["Return localized"]
    GBUSY --> EN_OUT
    TOUT --> EN_OUT
    ERR --> EN_OUT
    VFAIL --> EN_OUT

2.2 Pipeline steps — Cache priming path (ARQ worker)

flowchart TD
    ANALYSIS["Analysis pipeline hoàn thành<br/>trong ARQ Worker"]

    ANALYSIS --> CHECK{"Locale support enabled?<br/>User có locale preference?"}

    CHECK -->|No supported locales| SKIP["Skip priming<br/>0 cost"]

    CHECK -->|Yes| LOAD["Load analysis result<br/>Pydantic model từ DB"]

    LOAD --> CACHE_CHECK{"Cache đã tồn tại?<br/>(analysis rerun)"}

    CACHE_CHECK -->|Đã có| SKIP
    CACHE_CHECK -->|Chưa| COLLECT["Collect candidates<br/>(steps 1-3)"]

    COLLECT --> EMPTY{"Candidates > 0?"}
    EMPTY -->|Không| SKIP

    EMPTY -->|Có| TRIGGER["Kiểm tra concurrency guard<br/>(nếu busy → skip, lần sau sync sẽ fill cache)"]

    TRIGGER -->|Busy| SKIP_PRIME["Skip priming<br/>+ log guard_busy"]

    TRIGGER -->|OK| PRIME["LLM translate<br/>gpt-4o-mini<br/>timeout=30s"]

    PRIME -->|Success| WRITE_CACHE["SET cache key<br/>TTL=3600s"]
    PRIME -->|Timeout/Lỗi| SKIP_PRIME

    WRITE_CACHE --> DONE["Priming done ✅"]

2.3 Timing budget

=== HAPPY PATH (Cache hit) ===
Cache lookup (Redis):            0.01-0.02s
Pydantic validate:               0.01-0.05s
Total:                           0.02-0.07s → ~10ms 🚀

=== EDGE CASE (Cache miss - sync fallback) ===
Build English response:          0.1-0.3s
Cache lookup (miss):             0.01s
Chờ semaphore (nếu busy):       0-5s (tối đa)
LLM translate (gpt-4o-mini):    2-5s (typical)
                                8-15s (worst, batch lớn 12k chars)
Apply + validate:               0.05-0.1s
Write cache:                    0.01s
Total:                           2-6s (typical)
                                8-20s (worst)

=== ERROR PATH (LLM timeout → English fallback) ===
Build English response:          0.1-0.3s
Cache lookup (miss):             0.01s
LLM timeout sau 30s:             30s (wait_for timeout)
→ return English:                ~30s (worst)
+ enqueue ARQ retry              0.01s

Không có 504 vì:

  • Cache hit → 10ms (không đụng LLM)
  • Sync fallback → asyncio.wait_for(30s) + semaphore wait(5s) → tối đa 35s, dưới proxy timeout
  • Error → English ngay + ARQ retry, luôn HTTP 200

3. Cache Priming — Chi tiết

3.1 Injection point

File backend/src/app/services/analysis_pipeline.py (hoặc worker task tương ứng).

Sau khi analysis pipeline hoàn thành, thêm 1 dòng:

# Sau khi analysis result được persist vào DB
if settings.LOCALIZATION_ENABLED and settings.LOCALIZATION_PRIMING_ENABLED:
    await enqueue_job("localize_prime", analysis_id=analysis.id)

Hoặc gọi trực tiếp nếu không muốn tạo job riêng:

from ..services.localization import prime_localization_cache

# Trong worker, sau khi analysis xong:
await prime_localization_cache(analysis_result, supported_locales=["ja"])

3.2 prime_localization_cache() logic

async def prime_localization_cache(
    analysis: AnalysisDetailResponse,
    supported_locales: list[str],
) -> None:
    """Pre-translate analysis result cho tất cả supported locales (trừ canonical)."""
    for locale in supported_locales:
        if locale == LOCALIZATION_CANONICAL_LOCALE:
            continue
        try:
            # Giống hệt localize_response() nhưng không trả về, chỉ write cache
            decisions = await _call_translator(
                _prepare_candidates(analysis),
                locale=locale,
                context=LocalizationContext(surface="analysis_detail", ...),
            )
            merged = _apply_decisions(analysis.model_dump(mode="json"), decisions)
            validated = AnalysisDetailResponse.model_validate(merged)
            
            await _write_cache(
                key=_make_cache_key(analysis, locale),
                payload=validated.model_dump(mode="json"),
                ttl=LOCALIZATION_CACHE_TTL_SECONDS,
            )
        except Exception:
            logger.warning("localize_prime_failed", extra={"locale": locale})
            # Không fail pipeline — best-effort

3.3 Số lượng LLM calls

Scenario LLM calls/analysis Khi nào
Cache priming N supported locales (vd: 1 = ja) Ngay sau analysis complete
Sync fallback (cache miss) 1 User request đầu tiên
Cache hit 0 Mọi request sau

Với 1 locale (ja): 1 LLM call extra sau analysis. Analysis mất 2-5 phút, translation thêm 2-5s → không đáng kể.


4. ARQ Retry Worker

Khi sync fallback timeout/lỗi, enqueue job để translate background — lần sau user request sẽ cache hit.

Worker settings

# backend/src/app/workers/tasks/localization.py

async def localize_retry(ctx: dict, analysis_id: str, locale: str) -> None:
    """ARQ job: translate analysis trong background, write cache."""
    from ..services.localization import prime_localization_cache
    from ..crud.analysis import get_analysis
    
    analysis = await get_analysis(analysis_id)
    if not analysis:
        return
    
    await prime_localization_cache(analysis, supported_locales=[locale])
# backend/src/app/workers/settings.py — thêm vào WorkerSettings.functions
func(
    localize_retry,
    keep_result=0, 
    timeout=120,  # translation job timeout 120s
),

5. Config

# backend/src/app/core/config.py — thêm vào AnalysisSettings

# ── Localization ──────────────────────────────────────────────
LOCALIZATION_ENABLED: bool = False
LOCALIZATION_PRIMING_ENABLED: bool = True       # Cache priming tại pipeline completion
LOCALIZATION_MODEL: str = "gpt-4o-mini"         # Model nhanh cho translation
LOCALIZATION_PROMPT_VERSION: str = "v1"
LOCALIZATION_POLICY_VERSION: str = "v1"
LOCALIZATION_SUPPORTED_LOCALES: str = "en,ja"
LOCALIZATION_CANONICAL_LOCALE: str = "en"
LOCALIZATION_TIMEOUT_SECONDS: float = 30.0      # Timeout cho 1 LLM call
LOCALIZATION_SEMAPHORE_WAIT_SECONDS: float = 5.0 # Timeout chờ semaphore
LOCALIZATION_MAX_CHARS_PER_BATCH: int = 12000
LOCALIZATION_MAX_CANDIDATES: int = 200
LOCALIZATION_MAX_OUTPUT_TOKENS: int = 4000
LOCALIZATION_CACHE_ENABLED: bool = True
LOCALIZATION_CACHE_TTL_SECONDS: int = 3600
LOCALIZATION_DEBUG_HEADERS: bool = False
LOCALIZATION_ENABLED_SURFACES: str = "analysis_detail"

6. Implementation phases

Phase 0 — Nền tảng (1-2 days)

Task File Detail
0.1 frontend/lib/i18n/ Interceptor proxyApi set X-Scopelytics-Locale từ scopelytics.locale
0.2 backend/src/app/core/config.py Thêm LocalizationSettings vào Settings
0.3 backend/src/app/core/localization.py Mới: RequestLocale, LOCALE_LABELS, get_request_locale()
0.4 Tests Verify header resolve, unsupported locale fallback en
Đầu ra: locale đã vào BE, localizer chưa wire → no-op.

Phase 1 — Core service (3-4 days)

Task File Detail
1.1 services/localization/types.py Mới: StringCandidate, TranslationDecision, LocalizationContext
1.2 services/localization/json_collector.py Mới: Duyệt JSON, collect string leaf + path
1.3 services/localization/denylist.py Mới: Hard rules — ID, enum, URL, quote, timestamp
1.4 services/localization/surface_policy.py Mới: analysis_detail allowlist; surface khác deny-all
1.5 services/localization/cache.py Mới: Fingerprint Redis cache (best-effort)
1.6 services/localization/prompts.py Mới: Template system/user prompt
1.7 services/localization/ai_translator.py Mới: 1× OpenAI call via chat_completions_parse() + timeout
1.8 services/localization/response_localizer.py Mới: Orchestrator — steps 0→7 + timeout fallback
1.9 Endpoint GET /analysis/{id} Wire localize_response() sau build_english_response()
1.10 Tests Unit + integration + cache hit/miss/fallback
Đầu ra: locale=ja → analysis detail trả Japanese (cache hit instant, miss ~3-6s).

Phase 1.5 — Cache Priming (1 day)

Task Detail
1.5.1 Viết prime_localization_cache() trong localization service
1.5.2 Thêm localize_retry ARQ worker task
1.5.3 Wire priming vào analysis_pipeline.py sau khi persist
1.5.4 Thêm config LOCALIZATION_PRIMING_ENABLED
1.5.5 Metrics: localize_prime_count, localize_prime_fail
Đầu ra: Analysis xong → tự động pre-translate → user mở page → cache hit 10ms 🚀

Phase 2 — Mở rộng surface (2-3 days)

Task Detail
2.1 Wire bid_advisor_pack surface với allowlist riêng
2.2 Wire design_spec_run_detail (deny-all → bật từng field)
2.3 Thêm surface policy tests cho từng surface
2.4 Mở rộng cache priming cho surface mới

7. Fail-open guarantees

async def localize_response(
    model: BaseModel,
    locale: RequestLocale,
    *,
    context: LocalizationContext,
) -> BaseModel:
    """Localize response — guaranteed to return a valid model, never raises HTTP error."""

    # Step 0: Early exit
    if locale.code == LOCALIZATION_CANONICAL_LOCALE:
        return model
    if not settings.LOCALIZATION_ENABLED:
        return model

    try:
        payload = model.model_dump(mode="json")

        # Cache lookup
        if settings.LOCALIZATION_CACHE_ENABLED:
            cached = await _read_cache(_cache_key(payload, locale, context))
            if cached is not None:
                try:
                    return type(model).model_validate(cached)
                except Exception:
                    pass  # Cache corrupt → fall through to re-translate

        # Collect + filter candidates
        candidates = collect_strings(payload)
        translatable = policy_filter(candidates, context.surface)

        if not translatable:
            # Write empty cache to avoid re-checking
            await _write_cache(_cache_key(payload, locale, context), payload)
            return model

        # LLM translate with guard
        try:
            async with _localization_semaphore():
                decisions = await asyncio.wait_for(
                    _call_translator(translatable, locale, context),
                    timeout=settings.LOCALIZATION_TIMEOUT_SECONDS,
                )
        except (asyncio.TimeoutError, SemaphoreTimeout):
            # Fallback: English + enqueue ARQ retry
            await _enqueue_retry(context, locale)
            return model

        # Apply + validate
        merged = apply_decisions(payload, decisions)
        localized = type(model).model_validate(merged)

        # Write cache
        await _write_cache(_cache_key(payload, locale, context), merged)

        return localized

    except Exception:
        logger.exception("localization_fallback")
        return model  # English fallback — always HTTP 200

8. Monitoring & alerting

Metrics (Prometheus)

Metric Type Labels Ý nghĩa
localize_requests_total Counter locale, surface, result (hit/miss/fallback/error) Tổng số request localization
localize_cache_hit Counter status (hit/miss/error) Cache hiệu quả
localize_latency_ms Histogram surface, result Response time percentiles
localize_llm_timeout Counter surface LLM timeout rate
localize_guard_busy Counter Semaphore full
localize_prime_count Counter locale Số lần cache priming thành công
localize_prime_fail Counter locale, reason Số lần priming thất bại
localize_token_usage Counter model Cost tracking

Alerting

Alert Condition Action
Fallback rate > 5% localize_requests{result=fallback} / total > 0.05 Kiểm tra LLM health, timeout config
Prime fail rate > 10% localize_prime_fail / localize_prime_count > 0.1 Kiểm tra OpenAI availability
Cache hit rate < 60% localize_cache_hit{status=hit} / total < 0.6 Kiểm tra cache key, priming có chạy không

9. Risk matrix

Rủi ro Mức Impact Mitigation
LLM timeout khi concurrent full Low English fallback + ARQ retry Semaphore wait 5s + asyncio.wait_for 30s → không 504
LLM dịch sai requirement High Wrong content Denylist + surface allowlist + Pydantic validate
Cache priming tăng pipeline time 2-5s Low Pipeline chậm hơn 2-5s Chạy song song; không fail pipeline nếu priming lỗi
Cache poison (dữ liệu dịch sai) Medium Serve sai content Cache TTL 1h + fingerprint hash
Semaphore starvation (translation chiếm slot) Low Analysis chậm Translation dùng semaphore riêng hoặc ưu tiên thấp

10. Quyết định kiến trúc

ADR-001: Cache Priming tại analysis completion time

Context: LLM translate chậm gây 504 timeout nếu gọi đồng bộ trong HTTP request.

Giải pháp chọn: Pre-translate ngay khi analysis pipeline hoàn thành, trước khi user mở page.

# Trong ARQ worker, sau khi analysis persist:
await prime_localization_cache(analysis, locales=["ja"])

Lý do:

  • Analysis pipeline chạy 2-5 phút → translation thêm 2-5s không đáng kể
  • User chưa bao giờ thấy loading translation — cache luôn có sẵn
  • Không cần sửa FE, không cần polling/loading state
  • Tận dụng ARQ worker có sẵn

Fallback: Cache miss vẫn dùng sync LLM với timeout guard — không 504.

ADR-002: Dùng gpt-4o-mini cho translation

Context: Translation là task đơn giản (không cần reasoning).

Quyết định: gpt-4o-mini thay vì gpt-5-nano.

Lý do: Đã dùng trong project, latency 2-5s vs 10-15s, quality translation vẫn tốt.

ADR-003: Sync fallback + ARQ retry

Context: Cache miss không thường xuyên nhưng vẫn có thể xảy ra.

Quyết định: Sync LLM với timeout guard; nếu lỗi → English + ARQ retry background.

Lý do:

  • Sync đảm bảo UX đồng bộ (không flash)
  • Timeout guard đảm bảo không 504
  • ARQ retry đảm bảo lần sau user sẽ cache hit

11. File structure

backend/src/app/
├── core/
│   ├── config.py                          # + LocalizationSettings
│   └── localization.py                    # [MỚI] get_request_locale(), RequestLocale

├── services/
│   ├── analysis_pipeline.py               # [SỬA] + priming call sau persist
│   │
│   └── localization/                      # [MỚI] Package localization
│       ├── __init__.py                    # localize_response(), prime_localization_cache()
│       ├── types.py                       # Pydantic/dataclass types
│       ├── json_collector.py              # Duyệt JSON → StringCandidate[]
│       ├── denylist.py                    # Hard rules (code, không LLM)
│       ├── surface_policy.py             # Allowlist theo surface
│       ├── cache.py                       # Redis fingerprint cache
│       ├── prompts.py                     # System/user prompt templates
│       ├── ai_translator.py              # 1× OpenAI call via guard
│       └── response_localizer.py         # Orchestrator steps 0→7

└── workers/
    ├── settings.py                        # [SỬA] + localize_retry function
    └── tasks/
        └── localization.py               # [MỚI] ARQ task: localize_retry, localize_prime


11. Database Design — Localization Persistence Layer

11.1 Vấn đề: Tại sao cần DB?

Redis cache (TTL=3600s) đủ nhanh cho read-time, nhưng có điểm yếu:

Scenario Redis-only Redis + PostgreSQL
Redis restart / deploy 🔴 Cache miss → sync LLM 3-6s 🟢 Warm từ DB → cache hit
Thêm locale mới (vi) 🔴 Phải re-translate tất cả 🟢 DB có sẵn các locale khác
Cache eviction (memory full) 🔴 Mất translation 🟢 DB vẫn còn
Admin muốn review/edit translation 🔴 Không có UI 🟢 Query DB được
Backfill migration 🔴 Phải gọi LLM lại 🟢 DB là nguồn durable

Kết luận: Redis đủ cho "data ngay lập tức". DB cần cho persistence — translation không bị mất sau restart, deploy, eviction.

11.2 Thiết kế: Shadow Translation Table

Dùng pattern shadow table (giống supabase-lingo, Azure AI translation) — không sửa bảng gốc, DB vẫn canonical English:

-- Bảng shadow: lưu translation cho mọi string có thể dịch
CREATE TABLE localization_translations (
    id              BIGSERIAL PRIMARY KEY,
    
    -- Target
    surface         VARCHAR(64)     NOT NULL,   -- analysis_detail, bid_advisor_pack, ...
    row_id          INTEGER         NOT NULL,   -- PK của bảng gốc (vd: analysis_results.id)
    column_name     VARCHAR(128)    NOT NULL,   -- features, summary, task, ...
    json_path       VARCHAR(256)    NOT NULL,   -- JSON pointer: results[0].features[2].title
    locale          VARCHAR(8)      NOT NULL,   -- ja, vi, ko, ...
    
    -- Data
    source_text     TEXT            NOT NULL,   -- English gốc (để detect change)
    translated_text TEXT            NOT NULL,   -- Đã dịch
    prompt_hash     VARCHAR(64)     NOT NULL,   -- Phiên bản prompt đã dịch
    
    -- Metadata
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    is_active       BOOLEAN         NOT NULL DEFAULT TRUE,
    
    -- Constraints
    UNIQUE (surface, row_id, json_path, locale, prompt_hash)
);

-- Index cho lookup nhanh theo locale
CREATE INDEX idx_localization_lookup 
    ON localization_translations (surface, row_id, locale, prompt_hash)
    WHERE is_active = TRUE;

-- Index cho backfill / migration
CREATE INDEX idx_localization_locale
    ON localization_translations (locale, surface)
    WHERE is_active = TRUE;

11.3 Cache + DB: 2-Tier Architecture

        ┌──────────────────┐
        │   User Request   │
        │  locale != en    │
        └────────┬─────────┘

        ┌────────▼─────────┐
        │   Redis Cache     │  ← Tier 1: Hot cache (10ms)
        │   TTL=3600s       │
        └────────┬─────────┘

    ┌────────────┴────────────┐
    │                         │
    ▼                         ▼
┌──────────┐           ┌──────────┐
│ Cache    │           │ Cache    │
│ HIT 🚀   │           │ MISS     │
└──────────┘           └────┬─────┘

                    ┌──────▼──────┐
                    │ PostgreSQL   │  ← Tier 2: Persistent (5ms)
                    │ Shadow Table │
                    └──────┬──────┘

               ┌───────────┴───────────┐
               │                       │
               ▼                       ▼
        ┌──────────┐           ┌──────────┐
        │ DB HIT   │           │ DB MISS  │
        │→ Redis   │           │→ LLM     │
        │  warm    │           │  translate│
        │→ return  │           │→ DB write │
        └──────────┘           │→ Redis   │
                               │  write   │
                               │→ return  │
                               └──────────┘

11.4 Flow chi tiết: Read + Write

Read path (response_localizer.py):

async def _get_translated_payload(
    analysis_id: int,
    locale: str,
    surface: str,
    prompt_hash: str,
) -> dict | None:
    
    # Tier 1: Redis
    cache_key = f"localization:{surface}:{analysis_id}:{locale}:{prompt_hash}"
    cached = await redis.get(cache_key)
    if cached is not None:
        return json.loads(cached)
    
    # Tier 2: PostgreSQL
    rows = await db.execute(
        select(LocalizationTranslation)
        .where(
            LocalizationTranslation.surface == surface,
            LocalizationTranslation.row_id == analysis_id,
            LocalizationTranslation.locale == locale,
            LocalizationTranslation.prompt_hash == prompt_hash,
            LocalizationTranslation.is_active == True,
        )
    )
    
    if rows:
        # Warm Redis
        payload = _rebuild_payload_from_rows(rows)
        await redis.setex(cache_key, 3600, json.dumps(payload))
        return payload
    
    return None  # Cache miss → LLM translate

Write path (prime_localization_cache):

async def _save_translations(
    analysis_id: int,
    locale: str,
    surface: str,
    prompt_hash: str,
    decisions: list[TranslationDecision],
):
    # PostgreSQL — batch insert
    rows = [
        LocalizationTranslation(
            surface=surface,
            row_id=analysis_id,
            column_name=_extract_column(d.path),
            json_path=d.path,
            locale=locale,
            source_text=d.source_text,
            translated_text=d.translated_text or d.source_text,
            prompt_hash=prompt_hash,
        )
        for d in decisions
        if d.action == "translate"
    ]
    await db.execute(
        insert(LocalizationTranslation)
        .on_conflict_do_update(
            constraint="...",
            set_=dict(translated_text=..., prompt_hash=..., updated_at=func.now()),
        )
    )
    
    # Redis — set cache
    cache_key = f"localization:{surface}:{analysis_id}:{locale}:{prompt_hash}"
    await redis.setex(cache_key, 3600, _build_payload(decisions))

11.5 Backfill Migration

Khi thêm locale mới hoặc deploy lần đầu:

-- 1. Tạo bảng
CREATE TABLE localization_translations (...);

-- 2. Index
CREATE INDEX ...;

-- 3. ARQ job: translate tất cả analysis cũ
-- Job chạy background, batch 50 analysis / lần
# backend/src/app/workers/tasks/localization_backfill.py

async def backfill_localization_cache(ctx: dict, locale: str):
    """Backfill: translate all existing analyses for a new locale."""
    analyses = await get_all_completed_analyses()
    for batch in chunked(analyses, 50):
        async with asyncio.TaskGroup() as tg:
            for analysis in batch:
                tg.create_task(
                    prime_single_analysis(analysis, locale)
                )

11.6 So sánh: 3 Storage Strategies

Tiêu chí Redis-only PostgreSQL Shadow + Redis 🏆 PostgreSQL-only
Read latency 10ms 🚀 10ms (Redis) / 15ms (DB fallback) 15-20ms
Survive restart ❌ (nếu ko persist)
Survive eviction ✅ (DB fallback)
Phức tạp Thấp Trung bình Thấp
Admin UI ✅ (query DB)
Backfill Phải re-translate 1 lần, lưu vĩnh viễn 1 lần
Source of truth English (always) English (luôn canonical) English

11.7 Entity Relationship

erDiagram
    analyses ||--o{ analysis_results : has
    analysis_results ||--o{ localization_translations : "translated as"
    analyses ||--o{ localization_translations : "translated as"
    
    analyses {
        int id PK
        text title "English canonical"
        text description "English canonical"
        jsonb transcript_metadata
    }
    
    analysis_results {
        int id PK
        int analysis_id FK
        text summary "English canonical"
        jsonb features "English canonical"
    }
    
    localization_translations {
        bigint id PK
        varchar surface "analysis_detail"
        int row_id "FK to analyses.id or analysis_results.id"
        varchar column_name "features, summary, title..."
        varchar json_path "results[0].features[2].title"
        varchar locale "ja, vi, ko..."
        text source_text "English gốc để detect change"
        text translated_text "Đã dịch"
        varchar prompt_hash "Phiên bản prompt"
        timestamp created_at
        timestamp updated_at
        boolean is_active
    }

11.8 Cache Warm on Startup

Khi backend khởi động, warm Redis từ DB để tránh cache miss hàng loạt:

# backend/src/app/main.py — startup event

@app.on_event("startup")
async def warm_localization_cache():
    """Warm Redis cache from PostgreSQL on startup."""
    if not settings.LOCALIZATION_ENABLED:
        return
    
    # Warm các translation gần đây nhất
    recent = await db.execute(
        select(LocalizationTranslation)
        .where(LocalizationTranslation.is_active == True)
        .order_by(LocalizationTranslation.updated_at.desc())
        .limit(1000)
    )
    
    for row in recent:
        cache_key = f"localization:{row.surface}:{row.row_id}:{row.locale}:{row.prompt_hash}"
        await redis.setex(cache_key, 3600, row.translated_text)

12. So sánh 3 Approaches (Research-backed)

Nguồn tham khảo: AWS Database Blog (Feb 2026), BackendBytes, Google Cloud Translation Best Practices, Oracle Cloud Infrastructure Blog, IBM Research Queue Management, OpenAI API Docs, Anthropic Prompt Caching Docs.

Cách hoạt động: Pre-translate ALL locales tại analysis completion time trong ARQ worker. User request → Redis GET → trả translated trong 10ms. Nếu cache miss (edge case) → sync LLM fallback với timeout guard. Nếu LLM lỗi → English + ARQ retry background.

flowchart LR
    W["ARQ Worker<br/>(analysis xong)"] --> P["prime_localization_cache()<br/>for ALL locales"]
    P --> R["Redis SET<br/>localization:{id}:{locale}"]
    
    U["User request"] --> GET["Redis GET"]
    GET -->|Hit 🚀 10ms| TRANS["Trả translated"]
    GET -->|Miss| FALLBACK["Sync LLM gpt-4o-mini<br/>asyncio.wait_for(30s)"]
    FALLBACK -->|OK| TRANS
    FALLBACK -->|Timeout| EN["English + ARQ retry"]
Ưu điểm Nhược điểm
✅ Zero user-facing latency (cache hit 10ms) ❌ Tốn thêm (N_locales × 2-5s) trong pipeline
✅ Không 504, không flash UI ❌ Cần backfill migration cho analysis cũ
✅ Không cần sửa FE (loading/polling) ❌ Pipeline chậm hơn 2-5s (không đáng kể so với 2-5 phút)
✅ Tận dụng ARQ + Redis có sẵn
✅ Cache hit rate cao nhất (priming chủ động)
✅ UX đồng bộ, mượt mà

Industry references:


11.2 Approach 2: Pure Async Job Queue + Polling

Cách hoạt động: API trả về English ngay + job_id. FE polling GET /localization/status/{job_id}. Khi ARQ worker translate xong → FE fetch lại kết quả và render.

flowchart LR
    REQ["User request"] --> RES["HTTP 200 English<br/>+ X-Job-Id"]
    RES --> POLL["FE polling every 2s<br/>GET /localization/status/{id}"]
    
    ARQ["ARQ Worker"] --> TRANSLLM["Translate gpt-4o-mini"]
    TRANSLLM --> CACHE["Redis SET"]
    CACHE --> POLL
    
    POLL -->|Done| RENDER["FE fetch translated<br/>→ render lại"]
    POLL -->|Pending| POLL
Ưu điểm Nhược điểm
✅ API response luôn nhanh (10ms) Phải sửa FE: thêm polling logic, loading state
✅ Không lo timeout HTTP Flash UI: English → loading → translated
✅ GPU utilization cao hơn (Oracle) ❌ UX phức tạp: phải handle edge case polling timeout
❌ Translation chỉ 2-5s — không đủ "lâu" để justify async
❌ wasted LLM calls nếu user đóng tab trước khi polling xong

Industry references:


11.3 Approach 3: Synchronous Optimized + Timeout

Cách hoạt động: Giữ synchronous hoàn toàn. Dùng gpt-4o-mini (model nhanh nhất). Tăng HTTP timeout lên 60-120s. Fallback English nếu LLM timeout.

flowchart LR
    REQ["User request"] --> ACQ["Acquire semaphore<br/>wait max 5s"]
    ACQ -->|Busy| EN1["English fallback"]
    ACQ -->|OK| LLM["LLM gpt-4o-mini<br/>asyncio.wait_for 30s"]
    LLM -->|OK| TRANS["Trả translated<br/>+ Redis cache"]
    LLM -->|Timeout| EN2["English fallback"]
Ưu điểm Nhược điểm
✅ Đơn giản nhất, không infra mới First request luôn 3-6s (kể cả đã tối ưu)
✅ Không cần sửa FE Vẫn có thể 504: AWS API Gateway hard limit 29s
✅ Dễ debug, dễ maintain UX degrade khi concurrent cao: semaphore queue khiến request chờ
❌ Cache hit rate thấp (chờ user đầu tiên trigger)

Industry references:


11.4 Comparison Matrix

Tiêu chí Approach 1: Cache Priming 🏆 Approach 2: Async Polling Approach 3: Sync Optimized
UX lần đầu (cache miss) 10ms 🚀 10ms (English) 3-6s
UX lần đầu (cache hit) 10ms 🚀 10ms 🚀 10ms 🚀
Sửa FE Không (polling, loading) ❌ Không
504 timeout risk Không Không (proxy 30s) ❌
Flash UI (English→translated) Không Không
Infrastructure mới Không (ARQ+Redis có sẵn) ✅ Không Không
Cache hit rate Cao (priming chủ động) ✅ Thấp (chờ user trigger) ❌ Thấp (chờ user trigger) ❌
Complexity Trung bình Cao (FE+BE) ❌ Thấp
Time to implement 4-5 days 5-7 days 3-4 days
Backfill migration Cần (1 lần) Không cần Không cần
Industry consensus Recommended 🏆 Only for >30s tasks Legacy pattern

11.5 Decision Flowchart

flowchart TD
    START["Bạn muốn implement translation?"]
    
    START --> A{"Translation task có<br/>thường xuyên > 30s không?"}
    
    A -->|Có (video/image gen)| B["Approach 2: Async Polling"]
    
    A -->|Không (text translate 2-5s)| C{"Backend đã có<br/>ARQ Worker + Redis?"}
    
    C -->|Không| D["Approach 3: Sync Optimized<br/>(đơn giản, dễ maintain)"]
    
    C -->|Có ✅| E["Approach 1: Cache Priming<br/>tận dụng ARQ + Redis"]
    
    E --> F{"Có analysis cũ<br/>cần backfill không?"}
    
    F -->|Có| G["Chạy backfill job 1 lần<br/>+ bật priming cho analysis mới"]
    F -->|Không| H["Bật priming ngay<br/>cho analysis mới"]
    
    G --> DONE["Done: Zero-latency 🚀"]
    H --> DONE

11.6 Final Recommendation

├── Primary path:  Cache Priming (ARQ worker prime ALL locales)
│   ├── Cache hit  → 10ms response  🚀
│   ├── Cache miss → sync LLM fallback (gpt-4o-mini, 30s timeout)
│   └── LLM error  → English + ARQ retry background

├── Backfill:      Migration job for existing analyses (1 time)

└── FE change:     Zero (không cần sửa gì)

Lý do chọn Cache Priming:

  1. Tận dụng ARQ Worker + Redis có sẵn — không infra mới
  2. Translation chỉ 2-5s — không đủ lâu để justify async polling
  3. UX đồng bộ, không flash, không loading state
  4. Cache hit rate cao nhất vì priming chủ động
  5. Đây cũng là pattern được AWS, Google Cloud khuyến nghị cho translation caching
  6. Nếu sau này có locale mới (vi, ko, ...) → chỉ cần thêm 1 dòng config, priming tự động mở rộng

Appendix A — Cache key design

localization:{surface}:{locale}:{prompt_version}:{policy_version}:{sha256(canonical_payload)}
  • surface: analysis_detail, bid_advisor_pack, ...
  • locale: ja, vi, ko, ...
  • prompt_version: v1 — bump khi đổi prompt → invalidate all
  • policy_version: v1 — bump khi đổi denylist/allowlist
  • sha256(canonical_payload): JSON sort keys, compact separators

Ví dụ:

localization:analysis_detail:ja:v1:v1:a1b2c3d4e5...

Appendix B — Priming vs Cache warming vs Lazy caching

Strategy Khi chạy LLM calls UX impact
Cache priming (chọn) Ngay sau analysis complete 1 locale = 1 call Zero — user chưa mở page
Cache warming Scheduled / predictive Theo lịch Zero
Lazy caching (sync fallback) Khi user request đầu 1 call User đầu chờ 3-6s

Với architecture này, prime + lazy fallback phủ cả 2 trường hợp:

  • Normal: priming xong trước → user thấy translated ngay (10ms)
  • Edge: priming chưa xong → sync fallback (3-6s) → lần sau cache hit
  • Error: LLM lỗi → English + ARQ retry → lần sau cache hit