Skip to content

Fields::getTags() issues one cache DELETE per field, badly slowing admin pages on installs with many fields #2309

Description

@adrianbj

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.phpWireDatabasePDO 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions