Problem/Motivation

When drush config:import (or the Sync UI) runs a create operation for a config entity that already exists on the site, ConfigImporter::checkOp() deletes the existing entity instead of skipping or updating it. That delete is a normal runtime delete, so it cascades: every config entity that depends on the deleted one has onDependencyRemoval() fired on it, and those dependents may delete themselves, disable themselves, or rewrite themselves to drop the reference. The entity is then recreated under a new UUID, so anything that referenced the old UUID is also broken.

This is unexpected, unasked-for data loss in the active store. The only signal is a logged error, Deleted and replaced configuration entity "X" — it does not halt the import.

The collision arises on clean data. The importer builds — and, at the start of processConfigurations, re-diffs — its changelist, classifying the entity as a create because it is absent from the active store. The entity is then written into the active store during processConfigurations, as a side effect of an earlier config operation whose postSave / hook_ENTITY_TYPE_insert / ConfigEvents::SAVE handler creates a secondary config entity without checking isSyncing(). Because that write lands after the re-diff, the queued create is still pending; when the importer reaches it, the entity already exists and the delete-and-replace path runs.

Note what does not trigger it: simply installing a module in the same import does not, because ConfigInstaller deliberately skips config-entity creation while syncing (https://git.drupalcode.org/project/drupal/-/blob/main/core/lib/Drupal/Co...)

if ($this->isSyncing()) 
{ continue; }

and the importer re-diffs after extensions (https://git.drupalcode.org/project/drupal/-/blob/main/core/lib/Drupal/Co...). The trigger is specifically an isSyncing-unaware secondary config-entity write during config processing.

The relevant code is ConfigImporter::checkOp(), the case 'create' branch (around lines 976–993 in https://git.drupalcode.org/project/drupal/-/blob/main/core/lib/Drupal/Co...):

case 'create':
  if ($target_exists) {
    if ($entity_type_id = $this->configManager->getEntityTypeIdByName($name)) {
      // ...load the existing entity...
      $entity->delete();
      $this->logError($this->t('Deleted and replaced configuration entity "@name"', ['@name' => $name]));
    }
    // ...
    return TRUE;
  }
  break;

Steps to reproduce

The trigger is a secondary config-entity write during processConfigurations that ignores isSyncing(). A minimal module reproduces it deterministically — it emulates a contrib module whose SAVE handler writes a second config entity without the guard.

  1. Add a module secondary_write_demo with a ConfigEvents::SAVE subscriber that materialises a second config entity when a trigger config is saved, with no isSyncing() check:
    public function onSave(ConfigCrudEvent $event): void {
      if ($event->getConfig()->getName() !== 'user.role.demo_trigger') {
        return;
      }
      // No isSyncing() guard — this is the bug-arming pattern.
      $storage = $this->entityTypeManager->getStorage('user_role');
      if (!$storage->load('demo_target')) {
        $storage->create(['id' => 'demo_target', 'label' => 'Demo target'])->save();
      }
    }
    
  2. Enable it, stage two roles plus a dependent View, then make the active store differ so the import re-creates the roles:
    drush en secondary_write_demo -y
    drush role:create demo_trigger          # subscriber also creates demo_target
    drush config:set user.role.demo_target dependencies.config '["user.role.demo_trigger"]' -y
    drush config:set views.view.content dependencies.config '["user.role.demo_target"]' -y
    drush config:export -y
    drush sql:query "DELETE FROM config WHERE name IN ('user.role.demo_trigger','user.role.demo_target')"
    drush cr
    
  3. Import:
    drush config:import -y
    

Result: create user.role.demo_trigger fires the subscriber, which writes user.role.demo_target into the active store. The importer then reaches the queued create user.role.demo_target, sees it exists, and calls $entity->delete() — the freshly loaded entity's isSyncing() is FALSE, so ConfigEntityBase::preDelete() runs the dependency cascade. The dependent views.view.content is deleted, the role is recreated, and the importer logs Deleted and replaced configuration entity "user.role.demo_target". Confirm with drush config:get views.view.content (gone) and drush config:status (shows it only in sync).

Proposed resolution

Either of:

  • Treat a "create" that collides with an existing config entity as a fatal validation error, so the operator must fix the changelist; or
  • Detect the collision earlier in StorageComparer::createChangelist() and reclassify it as a paired delete + create on the same changelist, so dependency calculation handles both halves coherently instead of firing a stray runtime delete mid-import.

At minimum, this path should not silently cascade through a logError() that doesn't stop the import.

Remaining tasks

TBD

User interface changes

TBD

Introduced terminology

TBD

API changes

TBD

Data model changes

TBD

Release notes snippet

TBD

Comments

mingsong created an issue. See original summary.

mingsong’s picture

Issue summary: View changes
mingsong’s picture

Issue summary: View changes
mingsong’s picture

Related issue reported to facets module.

The fix for that module is to check the isSyncing() at

https://git.drupalcode.org/project/facets/-/commit/5401f0963dda44a4813fd...

larowlan’s picture

I think this is by design. Different UUID = different object

mingsong’s picture

Status: Active » Closed (works as designed)

Thanks Lee. I close it for now.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.