Skip to content

fix(gt-i18n): keep translations loaded after the cache expires - #36

Open
Kevin-Liu-01 wants to merge 2 commits into
mainfrom
k/gt-i18n-cache-expiry
Open

Kevin-Liu-01 wants to merge 2 commits into
mainfrom
k/gt-i18n-cache-expiry

Conversation

@Kevin-Liu-01

@Kevin-Liu-01 Kevin-Liu-01 commented Oct 12, 2026 •

Copy link
Copy Markdown

Summary

In the published SDK (gt-i18n 0.1.0 to 0.3.0), translations go back to the source language 60 seconds after startup. t() reads the cache through get_translations_sync(), which returned {} once an entry was older than cache_expiry_time, and nothing reloaded it. A live check on the published 0.1.0 answered "¡Hola, mundo!" at startup and "Hello, world!" 65 s later.

This PR fixes gt-i18n:

  • After an entry expires, the cache keeps serving the last loaded translations while one reload runs in the background. A reload that fails keeps the previous translations. Only a locale's first load makes its callers wait.
  • get_translations_sync() returns the last loaded translations even after they expire.
  • I18nManager gains get_translations_blocking() and load_all_translations_blocking() for synchronous code such as Flask, and a branch_id argument.
  • The CDN loader defaults to https://cdn.gtx.dev. Before, a manager without cache_url gave it an empty base URL.
  • The CDN loader appends /{version_id} and ?branchId={branch_id}, the same URL as the JS SDK's createRemoteTranslationLoader, and raises on an HTTP error or an empty response.
  • I18nManager(cache_url="") turns CDN loading off, and load_gt_config() maps _branchId to branch_id.
  • gt-i18n requires generaltranslation>=0.1.0 and generaltranslation-supported-locales>=0.0.6.

Size: 8 files, +657/-49. Product code is +212/-46, tests are +438/-1, pyproject.toml is +2/-2 and the changeset is +5.

Decisions

  • Locks are per locale. A synchronous loader's background reload runs on a daemon thread, which outlives a one-off asyncio.run(). An async loader called from async code reloads as a task on the caller's event loop, so a loader that holds a client bound to that loop keeps working. If that loop closes before the reload finishes, the reload finishes on a daemon thread.
  • First-load waiters are shielded, so cancelling one request does not cancel the load for the others.
  • A reload and load_all() share one load. Every load that runs on a thread holds the locale's lock, including a synchronous loader's loads from async code and load_all_blocking(), and skips its load when the locale turned fresh while it waited. An async loader's loads on one event loop share one task, and an expired async read starts no reload while a thread holds the lock. Threads that each run their own asyncio.run() with an async loader still each load.
  • A synchronous load_translations callback runs on a worker thread, off the event loop.
  • For the CDN, an empty {} counts as a failed load, because the edge answers backend errors with HTTP 200 and {} (apps/edge/src/index.ts in gt-cloud).
  • A failed attempt counts as a load, so the next retry waits for the next expiry.
  • The 60 s default stays, and initialize_gt still does not expose cache_expiry_time.
  • With this PR alone, translations loaded at startup no longer revert after 60 s. Flask and FastAPI start loading each configured locale per request, which also triggers the background reload, only with the next PR.

Test plan

After review, on 3b065a2 (origin/main is still c3fe22b on 2026-10-17), with Python 3.12.13:

  • uv lock --check, uv sync --all-packages --locked, ruff check ., ruff format --check . (177 files) and mypy . (164 source files): clean.
  • pytest: 1,426 passed. The review fix adds 6 tests to test_translations_manager.py. On this PR's first commit (0332c94), 4 of them fail: the async reload ran on another event loop, and load_all() and load_all_blocking() (with a sync and with an async loader) loaded the locale a second time during a slow reload. The other 2 cover the move to a thread when a one-off loop closes and the lock check, and each fails when its code is removed.
  • The gt-i18n tests (132) also pass on Python 3.10.20 and 3.14.6.

Before review, on this branch alone (k/gt-i18n-cache-expiry at 0332c94, on origin/main c3fe22b, still the latest main on 2026-10-11), with Python 3.12.13:

  • uv lock --check and uv sync --all-packages --locked: the lockfile does not change
  • ruff check .: clean. ruff format --check .: 177 files already formatted. mypy .: no issues in 164 source files.
  • pytest: 1,420 passed. That includes 21 new tests: 14 in test_translations_manager.py and 7 in the new test_remote_loader.py.
  • With the four gt-i18n source files reverted to origin/main, 19 of the 21 new tests fail. The two that pass on both check that concurrent first loads share one load and that the loader defaults to the CDN when called directly.

Earlier, on the combined SDK fix (this PR and the next): the SDK tests also passed on Python 3.10 and 3.14, two requests 65 s apart on real wall-clock time were both answered in Spanish, and two review passes ran with every finding fixed.

Merge order

  1. fix(release): publish every package version missing from PyPI (fix(release): publish every package version missing from PyPI #35 (k/publish-missing-versions)), which must merge before the release PR.
  2. This PR, then fix(gt-flask,gt-fastapi): load translations for every configured locale (fix(gt-flask,gt-fastapi): load translations for every configured locale #37 (k/flask-fastapi-configured-locales)), which is stacked on it.
  3. sampo's release PR, chore(release): bump versions and changelogs. It bumps gt-i18n, gt-flask and gt-fastapi to 0.3.1 and publishes them with the catch-up versions generaltranslation 0.1.0, generaltranslation-intl-messageformat 0.0.5 and generaltranslation-supported-locales 0.0.6.
  4. The docs PR (generaltranslation/content, docs(python): match the quickstart to the fixed SDK content#622 (k/python-quickstart-loads-translations)), after 0.3.1 is on PyPI.

Ernest McCarter wrote most of gt-python, so he should review this PR.

🤖 Generated with Claude Code

RetriggerConfidence Score: 3/5

Fix the async-loader loop change and competing reload paths before merging.

Fix All in Claude CodeFindings

  1. P1 Async loaders stop refreshing ▶
  2. P1 Reloads overwrite newer translations ▶
Fix with agent prompt
### Issue 1
packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py:115-116
Expired reads run custom async loaders on a new event loop in a daemon thread. If a loader reuses an async client or another object tied to the application's loop, its first load can succeed while every refresh fails. `_load_blocking` hides the error, and `_store` keeps renewing the old map, so translations never update.

Keep async loaders on their owning loop, with a separate path for short-lived loops.

### Issue 2
packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py:124
If `load_all_translations()` runs while a request reads an expired locale, `_start_load` starts one load and `_refresh_in_background` starts another. They use separate controls for the same locale. Both write the cache without checking which result is newer, so a slower response can overwrite translations already returned by the other load.

Make both paths share the same in-flight load.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Keeps loaded translations available after cache expiry and reload failures.

  • Expired translations stay available while the cache reloads.
  • Synchronous callers can load translations without awaiting.
  • CDN requests use the default host and selected translation version.

PR #37 supplies framework request loading, branch forwarding, and empty URL overrides. The assessment of those integration gaps depends on merging that PR; this PR does not contain those fixes.

Kevin-Liu-01 explicitly chose worker threads for synchronous callbacks and separate first loads across event loops. Kevin-Liu-01 deferred framework request-triggered refreshes to PR #37, which adds those calls.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Read[Read translations] --> Cached{Cache entry exists?}
    Cached -->|No| First[Wait for first load]
    Cached -->|Yes| Fresh{Entry still fresh?}
    Fresh -->|Yes| Return[Return cached translations]
    Fresh -->|No| Background[Start background reload]
    Background --> Return
    Background --> Result{Load succeeds?}
    Result -->|Yes| Replace[Replace cached translations]
    Result -->|No| Keep[Keep previous translations]
    First --> Return
Loading

Reviews (1) · Last reviewed commit: "fix(gt-i18n): keep translations loaded a..." · Reviewed by Greptile

t() reads the cache through get_translations_sync, which returned {} once
an entry was older than 60 s, and nothing reloaded it. Every locale went
back to the source text a minute after startup.

The cache now serves the last loaded translations after expiry while one
reload runs on a daemon thread, and keeps them when that reload fails.
Only a locale's first load makes callers wait. I18nManager gains
get_translations_blocking and load_all_translations_blocking for
synchronous code.

The CDN loader defaults to https://cdn.gtx.dev, appends version_id and
branch_id like the JS SDK, and treats a failed or empty response as a
failed load, because the CDN answers backend errors with 200 {}.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Comment on lines +115 to +116
# The reload runs on a thread, so it outlives this call's event loop.
self._refresh_in_background(locale)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Async loaders stop refreshing

Expired reads run custom async loaders on a new event loop in a daemon thread. If a loader reuses an async client or another object tied to the application's loop, its first load can succeed while every refresh fails. _load_blocking hides the error, and _store keeps renewing the old map, so translations never update.

Keep async loaders on their owning loop, with a separate path for short-lived loops.

Knowledge Base Used: Translation manager lifecycle

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py
Line: 115-116

Comment:
**Async loaders stop refreshing**

Expired reads run custom async loaders on a new event loop in a daemon thread. If a loader reuses an async client or another object tied to the application's loop, its first load can succeed while every refresh fails. `_load_blocking` hides the error, and `_store` keeps renewing the old map, so translations never update.

Keep async loaders on their owning loop, with a separate path for short-lived loops.

**Knowledge Base Used:** [Translation manager lifecycle](https://app.greptile.com/generaltranslation/-/custom-context/knowledge-base/generaltranslation/gt-python/-/docs/translation-manager-lifecycle.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b065a2. An async loader called from async code now reloads as a task on the caller's event loop, the loop its first load ran on, so a client bound to that loop keeps working. A reload whose loop closes before it finishes, as a one-off asyncio.run() does, moves to a daemon thread; test_async_loader_reloads_on_the_callers_event_loop and test_async_reload_finishes_after_a_one_off_event_loop_closes cover both paths.

"""Eagerly fetch translations for all *locales*."""
await asyncio.gather(*(self.get_translations(loc) for loc in locales))
"""Eagerly fetch translations for all *locales*, waiting for every load."""
tasks = [self._start_load(loc) for loc in locales if self._fresh(loc) is None]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reloads overwrite newer translations

If load_all_translations() runs while a request reads an expired locale, _start_load starts one load and _refresh_in_background starts another. They use separate controls for the same locale. Both write the cache without checking which result is newer, so a slower response can overwrite translations already returned by the other load.

Make both paths share the same in-flight load.

Knowledge Base Used: Translation manager lifecycle

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py
Line: 124

Comment:
**Reloads overwrite newer translations**

If `load_all_translations()` runs while a request reads an expired locale, `_start_load` starts one load and `_refresh_in_background` starts another. They use separate controls for the same locale. Both write the cache without checking which result is newer, so a slower response can overwrite translations already returned by the other load.

Make both paths share the same in-flight load.

**Knowledge Base Used:** [Translation manager lifecycle](https://app.greptile.com/generaltranslation/-/custom-context/knowledge-base/generaltranslation/gt-python/-/docs/translation-manager-lifecycle.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b065a2. With an async loader, the expired read and load_all() now join the same task on the loop. Every load that runs on a thread (a sync loader's loads, background reloads and load_all_blocking()) holds the locale's lock and skips its load when the locale turned fresh while it waited, and an expired async read starts no reload while a thread holds that lock. New tests start load_all() and load_all_blocking() during a slow reload and check that the loader runs once for it.

An expired read reloaded every locale on a daemon thread with a new
event loop. An async loader that holds a client bound to the app's loop
loaded once and then failed on every reload, and each failure kept the
old translations, so they never updated. load_all() and that reload also
used separate controls, so both could load a locale at the same time and
the slower response replaced the newer translations.

An async loader called from async code now reloads on the caller's
loop, in the task that load_all() and first loads on that loop join. If
the loop closes before the reload finishes, as a one-off asyncio.run()
does, the reload finishes on a daemon thread. Every load that runs on a
thread holds the locale's lock: a synchronous loader's loads from async
code, load_all_blocking() and the background reloads. An expired async
read starts no reload while a thread holds that lock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant