Issue #3585529: Add translation result caching and cross-field deduplication.
Description
Adds a translation result cache so the same text is not sent to an LLM twice.
TextTranslator::translateContent() is the single point where text reaches a
provider — src/TextTranslator.php:54 holds the only translateText() call, and all
five call sites route through it (the "Translate" tab batch, both interface
translation paths, and both Drush commands). This MR puts a cache in front of that
one seam.
It does so with a decorator, CachedTextTranslator, registered against
ai_translate.text_translator. TextTranslator and TextTranslatorInterface are
untouched, and the only existing file in src/ that changes is the settings form,
which gains one checkbox. That keeps the diff clear of the code the other open MRs
are working in.
Per @svendecabooter's scoping comment: based on 1.3.x, entry point contained to
translateContent(), a dedicated cache bin rather than cache.default, and
on for new installs, off for existing sites.
What is added
| File | Change |
|---|---|
src/CachedTextTranslator.php |
new — the decorator |
ai_translate.services.yml |
the cache.ai_translate bin and the decoration |
config/schema/ai_translate.schema.yml |
cache_translations boolean |
config/install/ai_translate.settings.yml |
cache_translations: true |
ai_translate.post_update.php |
sets it FALSE on existing sites |
src/Form/AiTranslateSettingsForm.php |
the checkbox |
.cspell-project-words.txt |
3 German words used in test fixtures |
tests/src/Unit/CachedTextTranslatorTest.php |
new — 9 tests |
tests/src/Kernel/TranslationCacheServicesTest.php |
new — 5 tests |
tests/modules/ai_translate_test/ |
new — a test module holding one stub translator the kernel tests count calls on |
tests/src/Functional/AiTranslateSettingsFormTest.php |
4 lines: 3 assertions for the checkbox and one $edit value |
docs/configuration.md |
a section for the new setting |
docs/index.md |
a Features bullet |
Against 1.3.x: 14 files changed, 696 insertions, 0 deletions. Nine existing
files touched, five new ones, and not a single line removed anywhere in the module.
Cache key and invalidation
Key: ai_translate:{from}:{to}:{sha256(text)}, with auto for an unknown source
language so it cannot collide with an explicit one.
Editing the source text changes the hash, so the stale entry is simply never asked for again — cross-run invalidation falls out of the key.
Output also depends on the model and the prompt, both editable in the UI. Keyed on text alone, an admin who tuned the prompt would keep receiving the old translations forever — a correctness bug, not a missed optimisation. Entries therefore carry:
config:ai.settings— the defaulttranslate_textandchatprovider and modelconfig:ai_translate.settings— which prompt is selectedconfig:ai.ai_prompt.<id>— the prompt body, for both the language-specific prompt and the default, becauseChatTranslationProviderfalls back to the default when the language-specific entity is empty
Config::save() invalidates config:<name> unconditionally
(core/lib/Drupal/Core/Config/Config.php:231), and config entities do the same via
ConfigEntityBase::getCacheTagsToInvalidate(), so saving any of those drops the
affected entries.
Points reviewers should know
1. Deduplication is the cache, not a second mechanism. The issue asks for two
things; they turn out to be one. Identical strings hash identically, and the batch
translates one field at a time, so the first occurrence's write lands before the
second occurrence's lookup. testRepeatedFieldsInOneRunCostOneProviderCall drives
this through the real batch callback and asserts the repeated string reaches the
provider once. The consequence is worth stating plainly: dedup only works when
caching is enabled. This is not the standalone in-memory grouping layer
the issue description sketches — building that would mean restructuring the batch so
something holds all the strings at once, which is the area #3585527 is working in.
2. Correction to an earlier comment on this issue. The per-language model field
in AiTranslateSettingsForm does not select the model. Its only reader is
src/Controller/ContentTranslationControllerOverride.php:173, where it builds the
label of the "Translate using @ai" link. The model that actually runs comes from
ai.settings via getDefaultProviderForOperationType(). That is why
config:ai.settings is in the tag set. The conclusion in that comment was right; the
reason given was not.
3. Coordination with #3585527. If translateMultiple() joins
TextTranslatorInterface, CachedTextTranslator stops being a valid implementation
and the container will fatal. It is a one-method passthrough to fix, but somebody has
to notice. Flagging it rather than pre-empting it, since that interface change is not
written yet.
4. hook_ai_translate_translation_alter() is invisible to this cache. That hook
(ChatTranslationProvider::loadTranslator()) lets any module rewrite the messages or
swap the provider at call time. A cache in front of translateContent() cannot see
it. A site whose hook varies per request should leave the feature off — which the
opt-out setting allows.
The same applies to $context, which TextTranslatorInterface documents as
carrying preferred_model, preferred_version and preferred_provider. It is
deliberately not part of the cache key: no caller in this project passes it and
TextTranslator ignores it, so it cannot affect output today. A future caller that
used it to select a model would be served a translation made with different
settings. Both limitations are stated in the class docblock. Happy to fold
$context into the key instead if you would rather the key be exhaustive.
5. Neither failures nor empty results are cached. $this->cache->set() sits
after the inner translateContent() call, so a TranslationException propagates
before anything is written — a rate-limit or outage cannot be stored as if it were a
translation.
An empty string needed handling separately, because it is a successful return
as far as the decorator is concerned. ChatTranslationProvider::translateText()
returns new TranslateTextOutput('', '', '') when the target language entity cannot
be loaded and again on a Guzzle failure, and both interface-translation call sites
already initialise $translation = '' and carry on — so it is a live path, not a
hypothetical. Cached at CACHE_PERMANENT it would pin the failure and make
"translate it again", the existing remedy for a bad translation, silently return the
same empty string. The write is therefore guarded with if ($translation !== ''),
and testEmptyTranslationIsNotCached() asserts the provider is called again.
6. Caching also makes translations reproducible. During testing the same source string produced a 223-byte German translation on one run and 222 bytes on another — the model simply varies. Without a cache, re-running a translation can silently reword published copy.
7. A named post_update hook, not ai_translate_update_10005(). Several MRs are
open against this module and any of them could claim 10005, at which point one
silently never runs on sites that installed the other first. A named post-update
cannot collide, and the module already has a post_update.php.
8. The issue description says "User interface changes: None"; this MR adds one. A checkbox on the AI Translate settings form, because @svendecabooter asked for the feature to be toggleable and for existing sites to be able to opt in. Flagging it so it does not read as scope creep against the description, which predates that comment.
9. A duplicate module in the test environment, and the workaround it forced.
The AI module still bundles the deprecated ai_translate submodule this project
replaced. core/tests/bootstrap.php registers one PSR-4 path per extension name
and the last one discovered wins — which is the bundled copy, deterministically,
both locally and in CI. Classes present in both copies still resolve, which is why
this has been invisible for years. CachedTextTranslator exists only here, so all
nine unit tests failed with Class "Drupal\ai_translate\CachedTextTranslator" not
found. Kernel and functional tests are unaffected: they resolve through the
container, which uses Drupal's own module list.
This is not caused by this MR — any MR adding a class to this project hits it, and this is simply the first one to do so.
CachedTextTranslatorTest::setUpBeforeClass() works around it by putting this
project's src/ first in the Composer autoloader. Five lines, reproduced and
verified with the bundled copy both present and absent.
Three alternatives, happy to take any:
- delete the bundled copy in
.gitlab-ci.yml's existing.phpunit-basebefore_script— fixes the cause rather than the symptom, but it is your CI file; - convert the nine unit tests to kernel tests — no workaround at all, roughly 12s instead of 0.03s;
- drop the workaround entirely once AI 2.0 removes the bundled submodule.
Worth knowing regardless of what this MR does, since it will affect every future class added here.
Open questions
These are my calls, not the issue's. Happy to change any of them.
- Decorator vs. editing
TextTranslatordirectly. A decorator keeps the diff off every existingsrc/file, which keeps this MR conflict-free against the others in flight. It is still "the cache in the TextTranslator service" from every caller's point of view. Would you rather it went inline? - Cache tags vs. putting provider/model/prompt in the key. Tags are simpler and
match the issue's "use standard cache tag infrastructure". The trade-off:
config:ai.settingschanges for reasons unrelated to translation, so it will sometimes over-invalidate. Putting them in the key would be precise and reversible — switch model, switch back, old entries still valid — at the cost of a longer key and no automatic invalidation when a prompt body is edited. CACHE_PERMANENTvs. a configurable max-age. Entries currently live until a tag invalidates them. Nobody specified a lifetime. Should this be a setting?
Testing instructions
Automated
Tests: 16, Assertions: 66, PHPUnit Deprecations: 4
exit=0Baseline on 1.3.x before this MR was 2 tests / 17 assertions / 4 deprecations. The
deprecation count is unchanged: the new test classes use PHP attributes
(#[Group], #[CoversClass]) rather than doc-comment metadata, which PHPUnit 11
deprecates.
phpcs --standard=Drupal— clean, exit 0. Also clean under--standard=Drupal,DrupalPractice, which is what the issue's task list asks for, though CI only runsDrupal(the project ships nophpcs.xml, so CI usesgitlab_templates/assets/phpcs.xml.dist, where theDrupalPracticerule is commented out)phpstanwith CI's configuration (gitlab_templates/assets/phpstan.neon:level: 0plus the#^Unsafe usage of new static#ignore) — 0 errorscspellwith CI's configuration — 0 issues in 57 files
The 9 unit tests cover: miss calls the provider and stores the result; hit does not
call the provider; a repeated string costs one provider call; edited source text
misses; a different target language misses; a NULL source language is keyed
separately; invalidating config:ai.settings forces re-translation; the setting off
bypasses the cache entirely; and an empty result is not cached.
The 5 kernel tests cover the container wiring (ai_translate.text_translator really
is the decorator, cache.ai_translate really exists); both halves of the default —
TRUE on a fresh install, FALSE after the post-update runs; that the decorator
reads from the dedicated bin rather than cache.default; that three fields carrying
two distinct strings through the real batch callback
(AiTranslateController::translateSingleField()) cost two provider calls, not three;
and that saving ai_translate.settings drops stored translations, rather than a
test invalidating the tag by hand.
The counting stub lives in tests/modules/ai_translate_test/ rather than under
tests/src/, because it is not a test. Drupal 10's TestDiscovery::scanDirectory()
collects every .php file under tests/src/ that is not a TestBase, Trait or
Interface and then rejects any class without a group — and CI runs
core/scripts/run-tests.sh, not vendor/bin/phpunit, because .gitlab-ci.yml sets
_PHPUNIT_CONCURRENT: 1. Drupal 11.4 uses PhpUnitTestDiscovery and is unaffected,
but the OPT_IN_TEST_PREVIOUS_MAJOR job would have failed discovery. Same layout
core uses for its own test doubles.
Those last three are deliberately mutation-tested. Repointing the decorator at
@cache.default fails the dedicated-bin test; making the decorator never read the
cache fails the repeated-fields test; dropping the config:ai_translate.settings tag
fails the settings-save test. Each was applied, run and reversed.
Manual, against a live provider
Verified with gemini_provider and models/gemini-flash-lite-latest.
Same string three times through ai_translate.text_translator:
call 1: 806.4 ms -> Erfahren Sie mehr über unsere Dienstleistungen
call 2: 0.2 ms -> Erfahren Sie mehr über unsere Dienstleistungen
call 3: 0.3 ms -> Erfahren Sie mehr über unsere DienstleistungenCross-field deduplication — a node whose title and body summary are the same string, so three extracted values become two provider calls:
ai_translate:en:de:090aee4cb58becb346a5ef95d5b9890a5befe564e89fd78cfc9a30dc3822e58a 23
ai_translate:en:de:a373c460c20a2303feec37e8319dc97344f8db4aefb343c47fa913df691103f8 222Deleting the German translation and re-translating the whole node, with the bin left populated:
cache rows: 2 (unchanged - zero provider calls for the entire node)
german title: Unsere DienstleistungenTag invalidation and the opt-out were not measured by hand on a live site; both are
covered deterministically by testSavingSettingsDropsStoredTranslations and
testDisabledSettingBypassesTheCache above.
Reproducing
- Configure any chat provider and set Translate text to Chat proxy to LLM.
- Add a second language, create a translatable node.
- Translate it; note the duration. Translate it again; it is near-instant.
SELECT cid, LENGTH(data) FROM cache_ai_translate;shows one row per distinct source string, keyedai_translate:{from}:{to}:{sha256}.- Save the settings form; the rows expire.
- Untick the setting; nothing is cached.
Release note
Translation results are now cached, so the same text is not sent to the AI provider twice. New installs have this enabled. Existing sites keep it disabled and can opt in at Configuration → AI → AI Translate → Cache translation results. Cached results are discarded automatically when the translation prompt or the default AI provider changes.
AI compliance
This code was generated by Claude Code, then reviewed, applied, run and verified by me. All tests and lint runs above were executed locally.
Closes #3585529