Short description
Field::setTags() invalidates the shared fields_tags cache on the first getTags() call for every Field — even when the field has no tags and nothing has changed. Since Fields::___getTags() loops over every field, one cold rebuild of the tag list issues one DELETE FROM caches WHERE name='fields_tags' per field.
On a production site with 576 fields this is 577 write transactions per affected request, adding ~4 seconds to field admin pages.
Details
Field::getTags() (wire/core/Field/Field.php:1468) initialises the tag list on first access:
public function getTags($getString = false) {
if($this->tagList === null) {
$tagList = $this->setTags(parent::get('tags'));
Field::setTags() (wire/core/Field/Field.php:1507) then does:
if($this->tagList !== $tagList) {
$this->tagList = $tagList;
parent::set('tags', implode(' ', $tagList));
$this->wire()->fields->getTags('reset');
}
On a freshly loaded Field, $this->tagList is null, so null !== array() is always true — including for a field with no tags whose stored value is unchanged. That fires Fields::getTags('reset'), which does $cache->delete('fields_tags').
And Fields::___getTags() (wire/core/Fields/Fields.php:1220) builds the list by looping every field:
foreach($this as $field) {
$fieldTags = $field->getTags();
So the very loop that rebuilds the tag cache deletes that cache once per iteration, and nulls Fields::$tagList each time as well.
Steps to reproduce
Save as tagtest.php in the PW root and run php tagtest.php:
<?php namespace ProcessWire;
require './index.php';
$db = wire('database');
$fields = wire('fields');
wire('cache')->delete('fields_tags');
$fields->getTags('reset');
$db->queryLog(true); // reset + start logging
$fields->getTags(); // cold rebuild
$deletes = 0;
foreach($db->queryLog() as $k => $sql) {
if($k === 'error') continue;
if(stripos($sql, 'DELETE') === 0 && stripos($sql, 'caches') !== false) $deletes++;
}
echo "fields: " . iterator_count($fields->getIterator()) . "\n";
echo "cache DELETEs: $deletes\n";
On a test install with 40 fields:
fields: 40
cache DELETEs: 41
Expected: 0 — nothing changed, so the cache should not be invalidated.
Actual: one DELETE per field, plus one.
Note: on large sites raise $config->dbQueryLogMax in site/config.php (not ready.php — WireDatabasePDO captures it at connection init), otherwise the query log saturates at 502 entries and hides the real count.
Why this is easy to miss in development
Development databases usually have binary logging off, so each of these DELETEs is nearly free. Production databases have it on, making every one a durable commit.
|
dev (local MariaDB) |
production (RDS MySQL) |
log_bin |
0 |
1 |
sync_binlog |
0 |
1 |
innodb_flush_log_at_trx_commit |
1 |
1 |
| cost per DELETE |
~0.28 ms |
~7.1 ms |
For comparison, on the same production server a plain SELECT costs 0.231 ms. Writes are ~30x more expensive than reads, so write count dominates.
Real-world impact
Production site, 576 fields, ProcessWire 3.0.270, MySQL on AWS RDS:
577 DELETEs x 7.1 ms = ~4.1 s
/setup/field/edit?id=N alternated between ~2.7s and ~6.8s on successive loads — a ~4.1s gap matching the arithmetic. Saving a field was worse, since it is a POST plus a redirected GET and each leg can pay the penalty.
The alternation is caused by the cache row's lifecycle: a cold request rebuilds and re-saves fields_tags (slow, N DELETEs), the next request reads it from cache (fast) but a later Field::getTags() invalidates it again, so the following request is cold once more.
Caught in information_schema.PROCESSLIST during a slow request:
TIME STATE INFO
0 Writing to binlog DELETE FROM caches WHERE name='fields_tags'
Suggested fix
Only reset the shared cache when the tag list is genuinely changing — not when it is being initialised from the already-stored value:
if($this->tagList !== $tagList) {
+ // initializing tagList from the already-stored value is not a change, so it must
+ // not reset the shared fields_tags cache; doing so makes Fields::getTags() issue
+ // one cache DELETE per field on every cold rebuild
+ $isInit = $this->tagList === null && implode(' ', $tagList) === (string) parent::get('tags');
$this->tagList = $tagList;
- parent::set('tags', implode(' ', $tagList));
- $this->wire()->fields->getTags('reset');
+ parent::set('tags', implode(' ', $tagList));
+ if(!$isInit) $this->wire()->fields->getTags('reset');
}
The $isInit guard is deliberately narrow: it only skips the reset when the resulting list is identical to what is already stored on the field, so an explicit setTags() with a genuinely new value still invalidates the cache even if tagList had not yet been initialised.
After applying:
- test install: 41 cache DELETEs → 0; cold
getTags() 11.4 ms → 4.2 ms; total queries 43 → 2
getTags() still returns the correct tags, and getTags(true) groupings are unchanged
- a real tag change still triggers exactly 1 cache DELETE
- production:
/setup/field/edit now consistently ~2.5–3.5s with the alternation gone (was 2.5–8.1s)
Environment
- ProcessWire 3.0.270
- PHP 8, Apache
- MySQL 8 on AWS RDS (production), MariaDB 12.3 (local test)
Field::setTags() carries an @since 3.0.106 annotation, so this most likely affects every version from 3.0.106 onward — though I have only verified it against 3.0.270.
Short description
Field::setTags()invalidates the sharedfields_tagscache on the firstgetTags()call for every Field — even when the field has no tags and nothing has changed. SinceFields::___getTags()loops over every field, one cold rebuild of the tag list issues oneDELETE FROM caches WHERE name='fields_tags'per field.On a production site with 576 fields this is 577 write transactions per affected request, adding ~4 seconds to field admin pages.
Details
Field::getTags()(wire/core/Field/Field.php:1468) initialises the tag list on first access:Field::setTags()(wire/core/Field/Field.php:1507) then does:On a freshly loaded Field,
$this->tagListisnull, sonull !== array()is always true — including for a field with no tags whose stored value is unchanged. That firesFields::getTags('reset'), which does$cache->delete('fields_tags').And
Fields::___getTags()(wire/core/Fields/Fields.php:1220) builds the list by looping every field:So the very loop that rebuilds the tag cache deletes that cache once per iteration, and nulls
Fields::$tagListeach time as well.Steps to reproduce
Save as
tagtest.phpin the PW root and runphp tagtest.php:On a test install with 40 fields:
Expected: 0 — nothing changed, so the cache should not be invalidated.
Actual: one DELETE per field, plus one.
Note: on large sites raise
$config->dbQueryLogMaxinsite/config.php(notready.php—WireDatabasePDOcaptures it at connection init), otherwise the query log saturates at 502 entries and hides the real count.Why this is easy to miss in development
Development databases usually have binary logging off, so each of these DELETEs is nearly free. Production databases have it on, making every one a durable commit.
log_binsync_binloginnodb_flush_log_at_trx_commitFor comparison, on the same production server a plain
SELECTcosts 0.231 ms. Writes are ~30x more expensive than reads, so write count dominates.Real-world impact
Production site, 576 fields, ProcessWire 3.0.270, MySQL on AWS RDS:
/setup/field/edit?id=Nalternated between ~2.7s and ~6.8s on successive loads — a ~4.1s gap matching the arithmetic. Saving a field was worse, since it is a POST plus a redirected GET and each leg can pay the penalty.The alternation is caused by the cache row's lifecycle: a cold request rebuilds and re-saves
fields_tags(slow, N DELETEs), the next request reads it from cache (fast) but a laterField::getTags()invalidates it again, so the following request is cold once more.Caught in
information_schema.PROCESSLISTduring a slow request:Suggested fix
Only reset the shared cache when the tag list is genuinely changing — not when it is being initialised from the already-stored value:
if($this->tagList !== $tagList) { + // initializing tagList from the already-stored value is not a change, so it must + // not reset the shared fields_tags cache; doing so makes Fields::getTags() issue + // one cache DELETE per field on every cold rebuild + $isInit = $this->tagList === null && implode(' ', $tagList) === (string) parent::get('tags'); $this->tagList = $tagList; - parent::set('tags', implode(' ', $tagList)); - $this->wire()->fields->getTags('reset'); + parent::set('tags', implode(' ', $tagList)); + if(!$isInit) $this->wire()->fields->getTags('reset'); }The
$isInitguard is deliberately narrow: it only skips the reset when the resulting list is identical to what is already stored on the field, so an explicitsetTags()with a genuinely new value still invalidates the cache even iftagListhad not yet been initialised.After applying:
getTags()11.4 ms → 4.2 ms; total queries 43 → 2getTags()still returns the correct tags, andgetTags(true)groupings are unchanged/setup/field/editnow consistently ~2.5–3.5s with the alternation gone (was 2.5–8.1s)Environment
Field::setTags()carries an@since 3.0.106annotation, so this most likely affects every version from 3.0.106 onward — though I have only verified it against 3.0.270.