Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ facade - is in the records 0016, 0017, 0022 and 0023: read them before "fixing"
([0019](docs/adr/0019-intl-is-optional.md)).
- Numbers shown to users go through `%F`, never `%f`: `sprintf()`'s `%f` follows `LC_NUMERIC`
and prints a decimal comma on a French server.
- `TranslationFileScanner` keeps each parsed file in the pool
`cyllene_digital_ai_translation.translation_files_cache` (adapter `cache.app`, 7 days), keyed
by its path, a hash of its content and `CACHE_FORMAT`: a change to what `parseFile()`
returns bumps `CACHE_FORMAT`, or hosts are served the old shape until their entries expire.
The walk of the directories is never cached, and the scanner is reset between two Messenger
messages. A file that cannot be read is reported, never cached
([0027](docs/adr/0027-parsed-files-cached-by-fingerprint.md)).
- `TranslationSuggestionRepository::transactional()` is hand-made on purpose:
`EntityManager::wrapInTransaction()` closes the host's entity manager on any exception, a
refused approval included ([0015](docs/adr/0015-hand-made-transactions.md)).
Expand Down
4 changes: 4 additions & 0 deletions config/definition.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
->min(0)
->defaultNull()
->end()
->booleanNode('translation_files_cache')
->info('Keeps each parsed translation file in the "cyllene_digital_ai_translation.translation_files_cache" pool (adapter cache.app, 7 days, both overridable under framework.cache.pools), keyed by its path and a hash of its content: a request reads and hashes the files instead of parsing them. false = parse on every new process.')
->defaultTrue()
->end()
->scalarNode('default_locale')
->info('The project\'s default locale: the coverage reference and the default generation source. Falls back to the framework default_locale matched against the available locales, then to the first available locale.')
->defaultNull()
Expand Down
45 changes: 45 additions & 0 deletions docs/adr/0027-parsed-files-cached-by-fingerprint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 0027. Parsed translation files are cached by their fingerprint, never the walk of the directories

- Status: accepted
- Date: 2026-10-15

## Context

The bundle parses the host's translation files itself, since the translator's compiled
catalogues mix in messages it does not expose. A view over every catalogue parses every file,
in every new process: under PHP-FPM, on every request. Files change without any event the
bundle could listen to: a deployment, an edit in development, a theme package updated.

## Decision

Each parsed file is kept in a cache pool, under a key made of its path, a hash of its content
and a format version. Nothing invalidates an entry: a file that changes gets a new key, and the pool's lifetime removes what is left behind. The walk of the
directories is not cached, so a file added or removed is seen at once. A file that cannot be
read is reported without being cached: making it readable changes nothing in its key. The cache is on by
default, the pool is the bundle's own, built on `cache.app`, and the scanner's in-memory memo
is reset between two Messenger messages.

## Alternatives rejected

- **A cache emptied at deployment**: no file access at all, but a file edited in place is never
seen, which is the usual case in development.
- **Symfony's `ConfigCache`**, as the translator does: resources are only checked in debug, so
production has the same blind spot, for more code written into the cache directory.
- **The date of modification and the size**: no read of the file, but a file rewritten within
the same second at the same size keeps the previous entry, and so does a release deployed
under the same path, with the dates a build froze, on a pool shared between releases.
Reading and hashing a file costs a fraction of reading its cache entry, and far less than
parsing it.

## Consequences

- A request reads and hashes every file of the catalogues it reads instead of parsing them.
- A shared pool needs nothing more, on several servers or between releases: an entry only
matches the content it was parsed from.
- What the scanner stores changes only with a new format version in the key. The version of
the loaders is not part of it: a parsing change in a new release of the translation
component is served once its entries expire, or the pool is emptied.

## Related

- [0001. Overrides are a database delta, the translation files are never written](0001-overrides-are-a-database-delta.md)
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ Alternatives rejected when there were some, Consequences, Related) and a line in
| 0024 | [The keys of a batch are reserved before the provider is called](0024-keys-reserved-before-the-provider-is-called.md) |
| 0025 | [A deleted scope is purged in a transaction of its own, its journal kept](0025-a-deleted-scope-is-purged-journal-kept.md) |
| 0026 | [A new capability asked of the host is a separate optional interface](0026-new-host-capabilities-are-optional-interfaces.md) |
| 0027 | [Parsed translation files are cached by their fingerprint, never the walk of the directories](0027-parsed-files-cached-by-fingerprint.md) |
4 changes: 2 additions & 2 deletions docs/concepts/catalogues.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ the ICU variant taking precedence (as in the translator). A locale written with
translation tools name their files (`messages.pt-BR.yaml`), is read with `_`: it is the
`pt_BR` locale, the spelling Symfony and the stored overrides use.

**Nothing is silently ignored**: a file whose name or content cannot be interpreted is listed
with its reason (`getIgnoredFiles()`), for the caller to surface as a warning.
**Nothing is silently ignored**: a file that cannot be read, or whose name or content cannot be
interpreted, is listed with its reason (`getIgnoredFiles()`), for the caller to surface as a warning.

The scanned directory is configured via `translations_path` (default:
`%kernel.project_dir%/translations`). `additional_paths` adds more roots (a theme, a
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ cyllene_digital_ai_translation:
# coverage command always recounts from scratch, whatever this setting.
coverage_cache_ttl: null

# Keeps each parsed translation file in the
# "cyllene_digital_ai_translation.translation_files_cache" pool, keyed by its
# path and a hash of its content: a new process reads and hashes the files
# instead of parsing them again. false = parse them in every new process. See
# operations/cache.md.
translation_files_cache: true

# Additional translation roots (label => directory), browsed as "@label/…"
# catalogues, e.g. the translations/ of theme packages. Within a domain, a
# "@label" override takes precedence over the main root's (as theme files do
Expand Down Expand Up @@ -138,6 +145,22 @@ cyllene_digital_ai_translation:
the unknown name then fails on the first generation, with the list of available providers.
Left unset, it falls back to the first entry of `providers`, or to the custom provider when
it is the only one.
- **`translation_files_cache`** stores into the pool
`cyllene_digital_ai_translation.translation_files_cache`, which the bundle declares with
`adapter: cache.app` and `default_lifetime: 604800` (7 days). A host entry for the same pool
under `framework.cache.pools` is merged over it, so either can be changed there:

```yaml
# config/packages/framework.yaml
framework:
cache:
pools:
cyllene_digital_ai_translation.translation_files_cache:
adapter: cache.adapter.php_files # read back through opcache
```

`bin/console cache:pool:clear cyllene_digital_ai_translation.translation_files_cache`
empties it alone.
- **`providers` may stay empty**: the override workflow works without AI; the interactive
console's generation entry explains that no provider is configured, and a host surface
listing suggestions can show its own warning.
Expand Down
28 changes: 27 additions & 1 deletion docs/operations/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Two layers sit between a stored override and the text on screen. Both are invalidated
**automatically** on every save/remove; this page is mostly here to explain what happens and
to know when to step in manually.
to know when to step in manually. A third one, under the bundle's own screens and commands,
keeps the parsed translation files: see [The parsed translation files](#the-parsed-translation-files).

## The layers

Expand Down Expand Up @@ -37,6 +38,31 @@ query per (locale, scope) per hour).
- **Direct writes bypassing `OverrideWriter`** (raw SQL, a fixture): no event is dispatched, so
the in-memory map of an already-running process is not dropped either (see the entry above).

## The parsed translation files

The bundle reads the host's translation files itself, to list the catalogues, compare the
locales and find the missing keys: the translator's compiled catalogues mix in the framework's
and the other bundles' messages. Parsing them is most of the time of a view over every
catalogue, so each parsed file is kept in the pool
`cyllene_digital_ai_translation.translation_files_cache` (`translation_files_cache: true`, the
default).

| What | How |
| --- | --- |
| Key | The file's path, a hash of its content, and a format version the bundle bumps when it changes what it stores |
| Invalidation | None needed: a file whose content changes gets a new key, the old entry is orphaned |
| Lifetime | 7 days (`default_lifetime` of the pool), which bounds the orphaned entries |
| Not cached | The walk of the directories: a file added or removed is seen on the next request. A file that cannot be read: once readable again, it is read on the next request |
| Long-running processes | The scanner is reset between two Messenger messages (`kernel.reset`): a worker sees an edited file without a restart |

A new release of the translation component that parses a file differently is only seen once
the entry expires: empty the pool with
`bin/console cache:pool:clear cyllene_digital_ai_translation.translation_files_cache`.
`cache:clear` is not enough when `APP_SHARE_DIR` is set, as the Symfony 7.4 and 8 skeletons do
(`var/share`): the filesystem `cache.app`, and this pool with it, then lives in that directory,
which `cache:clear` leaves alone. Changing the adapter or the lifetime: see the
[configuration reference](../configuration-reference.md#notes). To parse the files in every new process instead, set `translation_files_cache: false`.

## What the bundle does not touch

The HTTP cache, Varnish, or any application-side Twig fragment cache: an overridden label on an
Expand Down
5 changes: 5 additions & 0 deletions docs/operations/multi-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ workers only see the change once their `cache.app` entry is gone.
The same applies to the coverage report cache (`coverage_cache_ttl`), which lives in the same
pool.

The parsed translation files (`translation_files_cache`) are kept in a pool of their own,
built on `cache.app` by default. Shared or not, nothing goes wrong: the key holds each
file's path and a hash of its content, so a node only reads entries parsed from the content it
has, between releases too, whatever the dates of modification a build leaves.

Symfony's compiled catalogues (`kernel.cache_dir/translations`) are node-local too, but the
bundle never touches them: they hold the file translations, never the overrides, which are
applied on top at runtime.
Expand Down
2 changes: 2 additions & 0 deletions docs/public-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ page name against the code: a rename goes red until the page, and `UPGRADE.md`,

- **The configuration tree** under `cyllene_digital_ai_translation`: every key of the
[configuration reference](configuration-reference.md), its type and its default.
- **The cache pool `cyllene_digital_ai_translation.translation_files_cache`**: the name a
host's `framework.cache.pools` entry uses to change its adapter or lifetime.
- **The Messenger transport `cyllene_ai_translation`**, and the routing of
`GenerateSuggestionsMessage` to it: what a host's `messenger:consume` and an integration's
worker configuration name.
Expand Down
79 changes: 72 additions & 7 deletions src/Catalogue/TranslationFileScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\Loader\XliffFileLoader;
use Symfony\Component\Translation\Loader\YamlFileLoader;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Service\ResetInterface;

/**
* Reads the host project's translations/ directory — the single source of the
Expand All @@ -23,14 +25,22 @@
* getIgnoredFiles().
*
* Everything is memoized for the lifetime of the service (one request / one CLI run):
* browsing triggers several reads of the same catalogue in a row.
* browsing triggers several reads of the same catalogue in a row. Under that memo, each
* parsed file is kept in a persistent pool (translation_files_cache), keyed by its path
* and a hash of its content, so a new process reads and hashes the files instead of parsing
* them again. The walk of the directories is never cached: a file added or removed is seen at
* once. The memo is dropped between two Messenger messages (kernel.reset), so a worker
* sees an edited file.
*/
final class TranslationFileScanner
final class TranslationFileScanner implements ResetInterface
{
private const array SUPPORTED_EXTENSIONS = ['yaml', 'yml', 'xlf', 'xliff', 'json'];

private const string INTL_ICU_SUFFIX = '+intl-icu';

/** Part of every persistent cache key: bump it when what parseFile() returns changes. */
private const int CACHE_FORMAT = 1;

/** @var array<string, TranslationCatalogue>|null identifier => catalogue, sorted */
private ?array $catalogues = null;

Expand All @@ -57,15 +67,26 @@ final class TranslationFileScanner

/**
* @param array<string, string> $additionalPaths label => directory, browsed as "@label/…"
* @param CacheInterface|null $filesCache null when translation_files_cache is off
*/
public function __construct(
#[Autowire(param: 'cyllene_digital_ai_translation.translations_path')]
private readonly string $translationsPath,
#[Autowire(param: 'cyllene_digital_ai_translation.additional_paths')]
private readonly array $additionalPaths = [],
private readonly ?CacheInterface $filesCache = null,
) {
}

public function reset(): void
{
$this->catalogues = null;
$this->files = [];
$this->ignoredFiles = [];
$this->messagesCache = [];
$this->fileCache = [];
}

/**
* @return list<TranslationCatalogue>
*/
Expand Down Expand Up @@ -331,13 +352,57 @@ private function loadFile(array $file): array
return $this->fileCache[$file['path']];
}

// Checked here, not left to the loaders: the JSON one reads an unreadable file as empty.
// Never cached: a chmod or a chown changes nothing a cache key could see.
if (!is_readable($file['path'])) {
$this->ignoredFiles[$file['path']] = 'The file cannot be read: check its permissions.';

return $this->fileCache[$file['path']] = [];
}

$parsed = null;
if (null !== $this->filesCache && null !== $key = $this->persistentKey($file['path'])) {
$parsed = $this->filesCache->get($key, fn (): array => $this->parseFile($file));
}
$parsed ??= $this->parseFile($file);

// Recorded on every load, the cached ones included: an unparseable file stays
// reported for as long as it is unparseable, not only by the process that parsed it.
if (null !== $parsed['error']) {
$this->ignoredFiles[$file['path']] = $parsed['error'];
}

return $this->fileCache[$file['path']] = $parsed['messages'];
}

/**
* The content, not the date of modification: a build may freeze the dates, and a pool
* shared between releases deployed under the same path would serve the previous content.
* Null when the file cannot be read: it is then parsed as if there were no cache.
*/
private function persistentKey(string $path): ?string
{
$content = @file_get_contents($path);

if (false === $content) {
return null;
}

return 'file_'.hash('xxh128', self::CACHE_FORMAT."\0".$path."\0".$content);
}

/**
* @param array{path: string, locale: string, intlIcu: bool, extension: string} $file
*
* @return array{messages: array<string, string>, error: ?string}
*/
private function parseFile(array $file): array
{
try {
$catalogue = $this->getLoader($file['extension'])->load($file['path'], $file['locale'], 'messages');
} catch (\Throwable $e) {
$this->ignoredFiles[$file['path']] = \sprintf('The file could not be parsed: %s', $e->getMessage());

// Memoised too: an unparseable file must not be retried on every lookup.
return $this->fileCache[$file['path']] = [];
// Kept like a parsed file: an unparseable file must not be retried on every lookup.
return ['messages' => [], 'error' => \sprintf('The file could not be parsed: %s', $e->getMessage())];
}

$messages = [];
Expand All @@ -347,7 +412,7 @@ private function loadFile(array $file): array
}
}

return $this->fileCache[$file['path']] = $messages;
return ['messages' => $messages, 'error' => null];
}

private function getLoader(string $extension): LoaderInterface
Expand Down
Loading
Loading