Label lookups cached for the length of a request
A review of change 1482, with an inventory of every lookup that reads a file and one question: does the cache survive a language switch?
- Change
- 1482 · [BUGFIX] Cache label lookups for the length of a request
- Patch set
- 2 · 3f9c2e1a7d0 · 3 files, +64 −9
- Issue
- #2071 · Bug · "a page with forty labels answers in 130 ms"
- Target
- main · 2.4
- State
- mergeableVerified +1no votes, no comments, no chain
- Read
- 2026-09-11 in a worktree of its own, probes on 2026-09-14
The change fixes a real, measured fault: every label lookup read its catalogue file again, and a page with forty labels spent 124 of its 131 ms on the same three files. An array on the service, alive for one request, answers the second lookup of a key without a read. The idea is right and small.
Recommendation: not ready to merge. The unit suite for the class fails (F1.1), and a lookup after a language switch answers in the language before it (F1.2), which no test can see (F2.2). Key the cache on the language too, adapt the four tests that expect the second read, and bring the two tests in A.1. Nothing else in the change stops it.
The change
What the change is, where it stands, and how the review read it. Then how the code it touches works, what went wrong, and what the change does about it.
Context
Issue #2071 came in from a reader of the tool reference: a page that resolves forty labels answers in 130 ms, and the trace shows the same file read forty times. The change is small on purpose. It adds an array to the lookup service and reads from it before it reads from the file.
Patch set 2 is a rebase of set 1 with a changelog entry. It stands alone: no chain above it, no votes, no comments, and the pipeline reports green. The pipeline runs the functional suites and not the unit suite for Lookup, which is where F1.1 comes from.
The review read commit 3f9c2e1a7d0 in a worktree of its own, with no rebase, 4 commits behind origin/main. Every suite result and every probe in this document is against that commit and its parent.
How a lookup answers
A tool answers from a source, and the source of typo3_label_lookup is the catalogue files of the installation. The service resolves a key to a file, reads that file for one language, and returns the label. Nothing between the call and the file kept an answer, so every call was a read. The other lookups keep theirs somewhere, and where they keep it decides what a change to one of them can touch.
| Tool | Reads | Cached where | Per request |
|---|---|---|---|
| typo3_label_lookup | LabelLookup::resolve() | this change | with the change |
| typo3_icon_lookup | IconLookup::resolve() | IconRegistry, since 2.1 | yes |
| typo3_schema_lookup | SchemaLookup::table() | the installation’s own | yes |
| typo3_hint_lookup | HintLookup::find() | a static, per process | reads once |
The error
Every call of typo3_label_lookup reads the catalogue again. A page that resolves forty labels reads the same three files forty times, and the read is most of the answer.
# typo3_label_lookup · page 12 · 40 labels
read Resources/Private/Language/locallang.xlf (de) 3.1 ms
read Resources/Private/Language/locallang.xlf (de) 3.0 ms
read Resources/Private/Language/locallang.xlf (de) 3.2 ms
…
40 reads · 3 files · 124 ms of a 131 ms answer
What it does
resolve() keeps what it answered in an array on the service, which lives as long as the request. The second lookup of a key returns from there and reads no file.
public function resolve(string $key, string $language): ?string {+ if (isset($this->cache[$key])) {+ return $this->cache[$key];+ } $catalogue = $this->reader->read($this->fileFor($key), $language);- return $catalogue->get($key);+ return $this->cache[$key] = $catalogue->get($key); }
Paths traced
Every way through the changed method, followed by hand and then by probe.
| Scenario | Flow | Result |
|---|---|---|
| First lookup of a key | resolve() → reader->read() → get() → into the array | one read |
| Second lookup, same key, same language | the array answers | no read |
| Second lookup, same key, other language | the array answers, in the first language | wrong answer |
| Key with no label | null goes into the array; isset() is false for it | reads again |
| A second request | a new service, an empty array | unchanged |
| typo3_icon_lookup, typo3_schema_lookup | not touched | unchanged |
Findings
By weight, and numbered by it: what stops the change, what goes back to its author, what is worth a change, and what the review opened and found sound. Without the last group the author cannot tell what a review looked at from what it never reached. Every finding in one table first, each row a jump to its entry, and the work they ask for in a second.
| Entry | Kind | Origin | |
|---|---|---|---|
| F1.1 | The unit suite for the lookup fails: 6 of 41 tests | blocks | introduced by this change |
| F1.2 | A lookup after a language switch answers in the language before it | blocks | introduced by this change |
| F2.1 | A missing key reads the catalogue on every call | sent back | introduced by this change |
| F2.2 | No test can fail on what the change claims | sent back | introduced by this change |
| F3.1 | The reader is a new instance per call | worth a change | older than the change |
| F3.2 | The changelog entry names no visible change | worth a change | introduced by this change |
| F3.3 | The array has no bound | worth a change | introduced by this change |
| F3.4 | Two trailers in the message | worth a change | — |
| F4.1 | The cache lives exactly one request | checked | — |
| F4.2 | The other lookups stay as they are | checked | — |
To do
| To do | Kind | |
|---|---|---|
| T1.1 | Adapt the four tests that expect the second read, and run the suite before the next patch set. | blocks |
| T1.2 | Key the cache on the key and the language, or keep one array per language. | blocks |
| T2.1 | Use array_key_exists(), or make the docblock say that a miss reads again. | sent back |
| T2.2 | Bring the two tests in A.1, which fail on the parent. | sent back |
| T3.1 | Construct the reader once, in the constructor. | worth a change |
| T3.2 | Put the number into the entry: 131 ms to 11 ms, forty reads to three. | worth a change |
| T3.4 | Drop the second Signed-off-by trailer. | worth a change |
Blocks submission
The unit suite for the lookup fails: 6 of 41 tests
Every case that resolves one key twice fails. The change does not touch the suite, so nobody ran it, and the pre-merge pipeline goes red on it.
$ vendor/bin/phpunit tests/Unit/Lookup Tests: 41, Assertions: 97, Failures: 6.
Four of the six expect the second read the change removes on purpose: adapt those. The other two are F1.2.
T1.1Adapt the four tests that expect the second read, and run the suite before the next patch set.
A lookup after a language switch answers in the language before it
The cache keys on $key alone. The first answer for a key stays, whatever language the next call names. Probe P2 shows it, and the two remaining failures in F1.1 are this. Key on the key and the language, or keep one array per language.
T1.2Key the cache on the key and the language, or keep one array per language.
Sent back
A missing key reads the catalogue on every call
isset() is false for a cached null, so a key with no label goes to the file each time (probe P3). The docblock says the opposite: that the request keeps a miss. array_key_exists() does what the docblock says. Either the code or the sentence moves.
T2.1Use array_key_exists(), or make the docblock say that a miss reads again.
No test can fail on what the change claims
The coverage table below has the detail. In short: the one test named for the cache counts no reads and passes on the parent too, the language test never resolves one key twice, and a missing key has no test at all. A.1 is two tests that fail on the parent.
T2.2Bring the two tests in A.1, which fail on the parent.
Worth a change
The reader is a new instance per call
LabelLookup constructs its reader in resolve(). The service is a singleton, and the reader carries no state, so one reader in the constructor is the same object with one construction fewer per label.
T3.1Construct the reader once, in the constructor.
The changelog entry names no visible change
An answer that took 131 ms takes 11 ms, and a page with forty labels reads three files instead of forty. The entry says "performance". The number is the sentence a reader of the release page wants.
T3.2Put the number into the entry: 131 ms to 11 ms, forty reads to three.
The array has no bound
One request resolves at most a few hundred keys, so this costs nothing today. A sentence in the docblock that says so is enough; a bound is not.
Two trailers in the message
Signed-off-by stands twice. Nothing reads it twice; one goes.
T3.4Drop the second Signed-off-by trailer.
Checked and correct
The cache lives exactly one request
The service is request-scoped, and probe P5 shows a second request reads again. Nothing survives into the next answer.
The other lookups stay as they are
typo3_icon_lookup and typo3_schema_lookup cache already, in the registry and in the installation; the change reads neither. The hint lookup is a case of its own: the follow-up under Scope.
Remarks at the code
The hunks the findings point at, with each remark under the block at the line it cites. Line numbers are those of the patched file, and each remark goes into the review tool as it stands. A number in brackets names the finding.
41 public function resolve(string $key, string $language): ?string42 {43 if (isset($this->cache[$key])) {44 return $this->cache[$key];45 }46 $catalogue = $this->reader->read($this->fileFor($key), $language);47 return $this->cache[$key] = $catalogue->get($key);48 }
- 43
- The key has no language. A lookup after a switch answers in the language before it; probe P2 shows that. Key on both. [F1.2]
- 46
- This read runs again for a key with no label: isset() is false for a cached null. The docblock says the opposite; one of the two has to move. [F2.1]
- 47
- The one path that changed, and no test fails on it yet. A.1 is one. [F2.2]
60 private function reader(): CatalogueReader61 {62 return new CatalogueReader($this->files);63 }
- 62
- A new reader per call, in a service the container holds once. The constructor is the place; the reader carries no state of its own. [F3.1]
Evidence
What the review ran, on the change and on its parent, so every finding above has a number behind it.
Probes, parent against change
The probe in A.2, run against the change and against its parent. A probe that turns green proves the fix; one that turns red is a regression the suites did not see.
| Probe | Parent | Change |
|---|---|---|
| P1 · resolve() twice, one key, one language | fail 2 reads | ok 1 read |
| P2 · resolve() twice, de then en | ok answers in en | fail answers in de |
| P3 · a key with no label, twice | unchanged 2 reads | unchanged 2 reads |
| P4 · page 12, 40 labels | fail 40 reads, 131 ms | ok 3 reads, 11 ms |
| P5 · two requests in one process | ok 2 reads | ok 2 reads |
Suites on the worktree
Commit 3f9c2e1a7d0, every suite from a clean checkout.
| Suite | Scope | Result |
|---|---|---|
| unit | tests/Unit/Lookup | 6 of 41 fail |
| functional | tests/Functional/Tools | 88 pass |
| cgl | every PHP file | 0 fixable |
| phpstan | level 8, 412 files | no errors |
| e2e | — | not run |
Test coverage
What the tests of the changed class claim, against what each one checks. A test that passes on the parent too proves nothing about the change.
| Test | Claims | Checks | |
|---|---|---|---|
| resolveReturnsTheLabel | a key resolves | one call, one label | holds |
| resolveReadsTheCatalogueOnce | the cache works | nothing: it counts no reads, and passes on the parent too | claims more |
| resolveHonoursTheLanguage | a switch answers in the new language | one lookup per language, on two keys | misses F1.2 |
| — | a missing key | no test | none |
Scope
What the review considered and set aside, what it leaves for a change of its own, and what it ran.
Raised and dropped
- A cache across requests. Raised because the files change rarely. Dropped: a label edited while the server runs then arrives after a restart, which is the fault the follow-up below names in the hint lookup.
- The cache in the reader instead of the lookup. Raised because the reader is where the cost is. Dropped: the reader is a new instance per call (F3.1), so a cache there lives one call.
- A bound on the array. Raised in F3.3 and dropped there: a request resolves a few hundred keys at most.
Follow-ups, not this change
Surfaces and runs
- Worktree
- review/1482, no rebase, 4 commits behind origin/main
- Read
- the three changed files, the class’s tests, the changelog entry, the issue
- Suites
- unit for Lookup, functional for Tools, cgl, phpstan
- Probes
- five, on parent and change, in A.2
- Not run
- e2e, the full unit suite
Appendix
A.1 · The two tests, written for this review and removed again
public function aSecondLookupReadsNoFile(): void
{
$this->subject->resolve('answer.empty', 'de');
$this->subject->resolve('answer.empty', 'de');
self::assertSame(1, $this->reader->reads);
}
public function aLookupAfterALanguageSwitchAnswersInTheNewLanguage(): void
{
$this->subject->resolve('answer.empty', 'de');
self::assertSame('Nothing found', $this->subject->resolve('answer.empty', 'en'));
}
A.2 · The probe
$lookup = $container->get(LabelLookup::class);
$reader = $container->get(CatalogueReader::class);
$lookup->resolve('answer.empty', 'de');
$lookup->resolve('answer.empty', 'de');
printf("P1 reads after two lookups: %d\n", $reader->reads);
$en = $lookup->resolve('answer.empty', 'en');
printf("P2 after a switch to en: %s\n", $en);