Repository navigation
fix(gt-i18n): keep translations loaded after the cache expires - #36
Kevin-Liu-01 wants to merge 2 commits into
Conversation
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]>
| # The reload runs on a thread, so it outlives this call's event loop. | ||
| self._refresh_in_background(locale) |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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] |
There was a problem hiding this 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
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.There was a problem hiding this comment.
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]>
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 throughget_translations_sync(), which returned{}once an entry was older thancache_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:
get_translations_sync()returns the last loaded translations even after they expire.I18nManagergainsget_translations_blocking()andload_all_translations_blocking()for synchronous code such as Flask, and abranch_idargument.cache_urlgave it an empty base URL./{version_id}and?branchId={branch_id}, the same URL as the JS SDK'screateRemoteTranslationLoader, and raises on an HTTP error or an empty response.I18nManager(cache_url="")turns CDN loading off, andload_gt_config()maps_branchIdtobranch_id.generaltranslation>=0.1.0andgeneraltranslation-supported-locales>=0.0.6.Size: 8 files, +657/-49. Product code is +212/-46, tests are +438/-1,
pyproject.tomlis +2/-2 and the changeset is +5.Decisions
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.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 andload_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 ownasyncio.run()with an async loader still each load.load_translationscallback runs on a worker thread, off the event loop.{}counts as a failed load, because the edge answers backend errors with HTTP 200 and{}(apps/edge/src/index.tsin gt-cloud).initialize_gtstill does not exposecache_expiry_time.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) andmypy .(164 source files): clean.pytest: 1,426 passed. The review fix adds 6 tests totest_translations_manager.py. On this PR's first commit (0332c94), 4 of them fail: the async reload ran on another event loop, andload_all()andload_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.Before review, on this branch alone (
k/gt-i18n-cache-expiryat 0332c94, on origin/main c3fe22b, still the latest main on 2026-10-11), with Python 3.12.13:uv lock --checkanduv sync --all-packages --locked: the lockfile does not changeruff 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 intest_translations_manager.pyand 7 in the newtest_remote_loader.py.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
k/publish-missing-versions)), which must merge before the release PR.k/flask-fastapi-configured-locales)), which is stacked on it.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
Fix the async-loader loop change and competing reload paths before merging.
Fix with agent prompt
Summary
Keeps loaded translations available after cache expiry and reload failures.
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 --> ReturnReviews (1) · Last reviewed commit: "fix(gt-i18n): keep translations loaded a..." · Reviewed by Greptile