When using a Source Plugin that uses Generators in their initializeIterator() function, drush migrate-import cannot be run with the --sync option, because rewind() is called twice on the iterator (once for the sync, and again for the import). Generator iterators cannot be rewound if they've already been used.

See for example migrate_source_csv's CSV::initializeIterator():

  public function initializeIterator() {
    $header = $this->getReader()->getHeader();
    if ($this->configuration['fields']) {
      // If there is no header record, we need to flip description and name so
      // the name becomes the header record.
      $header = array_flip($this->fields());
    }
    return $this->getGenerator($this->getReader()->getRecords($header));
  }

  protected function getGenerator(\Iterator $records) {
    foreach ($records as $record) {
      yield $record;
    }
  }

It's likely migrate_source_csv has done this to save memory.

Options:
1) Force classes that extend SourcePluginBase's initializeIterator function to not use Generators
2) Create a new migration object for the sync, and then for the import, so that the iterator isn't reused.

Original Description:

If I use migrate_source_csv, and attempt to run a migrate import with --sync to delete previously imported objects, drush throws the following error:

drush mim my_import  --sync
 [notice] Rolled back 32 items - done with 'my_import'
 [error]  Migration failed with source plugin exception: Cannot rewind a generator that was already run 

It's not clear to me how Migrate Source CSV is at fault here. The generator rewinds, then steps through the generator twice. The first time in migrate_tools, then later in core's migrate.

First, the sync runs. and migrate_tools' MigrateImportSync::sync() runs the following block of code, rewinding then stepping through $source:

      $source = $migration->getSourcePlugin();
      $source->rewind();
      $source_id_values = [];
      while ($source->valid()) {
        $source_id_values[] = $source->current()->getSourceIdValues();
        $source->next();
      }

Later, migrate's MigrateExecutable::import() runs this block of code, rewinding then attempting to (and failing) to step through $source:

    $source = $this->getSource();
    $id_map = $this->getIdMap();

    try {
      $source->rewind();
    } catch (\Exception $e) {
      $this->message->display(
        $this->t('Migration failed with source plugin exception: @e', ['@e' => $e->getMessage()]), 'error');
      $this->migration->setStatus(MigrationInterface::STATUS_IDLE);
      return MigrationInterface::RESULT_FAILED;
    }

There's no call whatsoever to rewind or next in migrate_source_csv.

Both $source's appear to be the same object through. The first is a CSV object, the second is a migrate_tools SourceFilter, though rewind() seems to reference the same object.

Could it be that the $source's iterator needs to be somehow reset between sync and import, and migrate_source_csv isn't properly doing this? The second call from MigrateExecutable::import() is returning the same iterator previous used in MigrateImportSync::sync()

BOTH could be cloned and it might work, let me try that...

Comments

TrevorBradley created an issue. See original summary.

trevorbradley’s picture

trevorbradley’s picture

Issue summary: View changes

NOTE: I am not suggesting the following as a solution!

For reference, if I modify migrate's SourcePluginBase to add the following function:

  public function resetIterator() {
    $this->iterator = $this->initializeIterator();
  }

And then modify MigrationImportSync::sync() to add a single line at the end of the if loop:

      $this->dispatcher->dispatch(MigrateEvents::POST_ROLLBACK, new MigrateRollbackEvent($migration));
      $source->resetIterator();
      $id_map->prepareUpdate();
      $source = clone $migration->getSourcePlugin();
      $source->rewind();

then migrate import with sync works exactly as expected.

Aha! I found another solution:

If instead I swap out the following line in MigrationImportSync::sync():

Replace:

      $id_map->prepareUpdate();
      $source = $migration->getSourcePlugin();
      $source->rewind();

With:

      $id_map->prepareUpdate();
      $source = clone $migration->getSourcePlugin();
      $source->rewind();

and ensure to run drush cim --sync with "--skip-progress-bar", it also works. The issue here is that MigrateDrushCommandProgress::initializeProgress calls count(), which initializes the generator early. If the progress bar is skipped, the generator isn't initialized until sync is actually run.

trevorbradley’s picture

I don't suggest this as a proper solution, but it *does* work.

heddn’s picture

Status: Active » Needs work
Issue tags: +Needs tests

This could use a test. A simple wrapper around embed that yields its results would be enough me thinks.

trevorbradley’s picture

I've spent yesterday afternoon and this morning figuring out PHPUnit and comparing my migrate-import script with migrate_tools' DrushCommandsTest.

Without my patch, for some reason, the migrate_tools test is just fine, but my migrate import using drush and migrate_source_csv fails.

DrushCommandsTest's version seems to instantiate a new MigrateExecutable between the sync and the import (creating a new Generator), where as my drush import csv doesn't.

Logic suggests it's either a migrate_source_csv issue, or a user error of some kind, but I'm not sure which.

I'm going to continue to investigate.

trevorbradley’s picture

OK, I understand the problem at least now. I'm using two different SourcePlugins.

DrushCommandsTest uses an EmbeddedDataSource, as defined in migrate_plus.migration.fruit_terms.yml.

EmbeddedDataSource::initializeIterator() is defined as:

  public function initializeIterator() {
    return new \ArrayIterator($this->dataRows);
  }

However, my CSV code is using migrate_source_csv's CSV source plugin. There, CSV::initializeIterator() is defined as:

  public function initializeIterator() {
    $header = $this->getReader()->getHeader();
    if ($this->configuration['fields']) {
      // If there is no header record, we need to flip description and name so
      // the name becomes the header record.
      $header = array_flip($this->fields());
    }
    return $this->getGenerator($this->getReader()->getRecords($header));
  }

In short, EmbeddedDataSource uses an ArrayIterator. CSV uses a Generator iterator. Generators cannot be rewound, but ArrayIterators can.

It all makes sense, migrate_source_csv likely did this to save memory on large CSV imports.

I'm going to update the ticket title and description. Not clear where to proceed from here.

@heddn - is this a migrate_source_csv issue, or a migrate_tools issue?

trevorbradley’s picture

Title: Migrate import sync throws "Cannot rewind a generator that was already run" » Cannot use Source Plugin that uses Generators with import sync
Issue summary: View changes
Status: Needs work » Active
heddn’s picture

Status: Active » Needs review
Issue tags: -Needs tests
StatusFileSize
new13.34 KB
new14.69 KB

This means that we need to bump our support for migrate source csv to be ^3.0. Let's see what that means for various tests.

heddn’s picture

StatusFileSize
new37.44 KB
new27.67 KB

Hmm, here we do a drastic thing and remove the web ui components for managing csv imports.

heddn’s picture

StatusFileSize
new495 bytes
new37.88 KB
new37.97 KB
heddn’s picture

StatusFileSize
new3.26 KB
new41.23 KB
trevorbradley’s picture

Just a note that migrate_source_csv:3.1 and migrate_source_csv's current dev branch still both use generators instead of simple iterators. This isn't as simple as switching migrate_source_csv to ^3.

heddn’s picture

Going to commit this to a new 5.x branch in prep for 8.8+/9.0 support.

heddn’s picture

Status: Needs review » Fixed

We can entertain backporting to 4.x, but given all the moving parts... it might be difficult. If someone wants to backport the fix only without any test changes, that might be the best way to go about backporting.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.