diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e0ad46e295f23de4f6927044a9cb3a6e0bc3ae06..b686610940ca2602ec3c7fbc6c7d1ca95805e827 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -65,11 +65,8 @@ variables:
   # Broaden test coverage.
   OPT_IN_TEST_PREVIOUS_MAJOR: 1
   OPT_IN_TEST_MAX_PHP: 1
-  OPT_IN_TEST_PREVIOUS_MINOR: 1
   OPT_IN_TEST_NEXT_MINOR: 1
   OPT_IN_TEST_NEXT_MAJOR: 1
-  # Show more log output
-  _PHPUNIT_EXTRA: --verbose
   # Convenient, and we have no secrets.
   _SHOW_ENVIRONMENT_VARIABLES: 1
 
diff --git a/composer.json b/composer.json
index 30e744da38c62c7c6a1da8b0fe11c6d85c21bced..563e44775c856af9758a58a4dd8bab193ebc6891 100644
--- a/composer.json
+++ b/composer.json
@@ -21,7 +21,7 @@
         "source": "https://git.drupalcode.org/project/migrate_tools"
     },
     "license": "GPL-2.0-or-later",
-    "minimum-stability": "dev",
+    "minimum-stability": "stable",
     "prefer-stable": false,
     "config": {
         "preferred-install": "dist"
diff --git a/migrate_tools.install b/migrate_tools.install
new file mode 100644
index 0000000000000000000000000000000000000000..c8ae3ad567a3d567a19dcb9263747c0fceb160e5
--- /dev/null
+++ b/migrate_tools.install
@@ -0,0 +1,48 @@
+<?php
+
+/**
+ * Implements hook_schema().
+ */
+function migrate_tools_schema(): array {
+  $schema['migrate_tools_sync_source_ids'] = [
+    'description' => 'Table storing SyncSourceIds entries for the --sync option.',
+    'fields' => [
+      'id' => array(
+        'description' => 'Primary Key: Unique ID.',
+        'type' => 'serial',
+        'not null' => TRUE,
+      ),
+      'migration_id' => [
+        'description' => 'The migration ID.',
+        'type' => 'varchar',
+        'length' => 255,
+        'not null' => TRUE,
+      ],
+      'source_ids' => [
+        'description' => 'Array of source IDs, in the same order as defined in \Drupal\migrate\Row::$sourceIds.',
+        'type' => 'blob',
+        // "normal" size for "blob" is 16KB on MySQL, and 4GB or unlimited on
+        // other DBMS. That should more than enough for a single set of IDs.
+        // @see https://www.drupal.org/node/159605
+        'size' => 'normal',
+        'not null' => TRUE,
+        'serialize' => TRUE,
+      ],
+    ],
+    'indexes' => [
+      'migration_id' => ['migration_id'],
+    ],
+    'primary key' => ['id'],
+  ];
+  return $schema;
+}
+
+/**
+ * Adds a table in the database dedicated to SyncSourceIds entries.
+ */
+function migrate_tools_update_10000(): void {
+  $schema = migrate_tools_schema();
+  foreach($schema as $tableName => $schemaDefinition) {
+    \Drupal::database()->schema()->createTable($tableName, $schemaDefinition);
+  }
+}
diff --git a/migrate_tools.module b/migrate_tools.module
index a0c9da4552873347ffe663606c9410cb906df62b..8a9ffb5fe23cfdd1ef17b662f80e5d86a8d442d4 100644
--- a/migrate_tools.module
+++ b/migrate_tools.module
@@ -23,6 +23,7 @@ use Drupal\migrate_tools\Form\MigrationEditForm;
 use Drupal\migrate_tools\Form\MigrationGroupAddForm;
 use Drupal\migrate_tools\Form\MigrationGroupDeleteForm;
 use Drupal\migrate_tools\Form\MigrationGroupEditForm;
+use Drupal\migrate_tools\MigrateTools;
 
 /**
  * Implements hook_entity_type_build().
@@ -55,7 +56,7 @@ function migrate_tools_entity_type_build(array &$entity_types): void {
 /**
  * Implements hook_menu_links_discovered_alter().
  */
-function migrate_tools_menu_links_discovered_alter(&$links) {
+function migrate_tools_menu_links_discovered_alter(&$links): void {
   if (\Drupal::moduleHandler()->moduleExists('migrate_plus')) {
     $links['migrate_tools.menu'] = [
       'title' => 'Migrations',
@@ -82,19 +83,20 @@ function migrate_tools_migration_plugins_alter(array &$migrations): void {
 /**
  * Implements hook_migrate_prepare_row().
  *
+ * @throws \Exception
+ *
  * @see \Drupal\migrate_tools\EventSubscriber\MigrationImportSync
  * @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase::next()
  */
-function migrate_tools_migrate_prepare_row(Row $row, MigrateSourceInterface $source, MigrationInterface $migration) {
-
-  if (!empty($migration->syncSource)) {
+function migrate_tools_migrate_prepare_row(Row $row, MigrateSourceInterface $source, MigrationInterface $migration): void {
+  /** @var MigrateTools $migrateTools */
+  $migrateTools = \Drupal::service('migrate_tools.migrate_tools');
+  // Act on migrations that have a Sync source, and that are currently in the
+  // phase of Syncing their IDs.
+  if (!empty($migration->syncSource) && $migrateTools->isMigrationSyncing($migration->getPluginId())) {
     // Keep track of all source rows here, as SourcePluginBase::next() might
     // skip some rows, and we need them all to detect missing items in source to
     // delete in destination.
-    $source_id_values = \Drupal::state()->get('migrate_tools_sync', []);
-
-    $source_id_values[] = $row->getSourceIdValues();
-
-    \Drupal::state()->set('migrate_tools_sync', $source_id_values);
+    $migrateTools->addToSyncSourceIds($migration->getPluginId(), $row->getSourceIdValues());
   }
 }
diff --git a/migrate_tools.routing.yml b/migrate_tools.routing.yml
index bfe0d1319bac2fc49c59788acd0449d80f62082b..dc569aa2e581623fda712a33d4c04fa1879ed2d4 100644
--- a/migrate_tools.routing.yml
+++ b/migrate_tools.routing.yml
@@ -80,20 +80,6 @@ entity.migration.process:
         type: entity:migration
       migration_group:
         type: entity:migration_group
-entity.migration.process.run:
-  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/process/run'
-  defaults:
-    _controller: '\Drupal\migrate_tools\Controller\MigrationController::run'
-    _title: 'Run'
-    _migrate_group: true
-  requirements:
-    _permission: 'administer migrations'
-  options:
-    parameters:
-      migration:
-        type: entity:migration
-      migration_group:
-        type: entity:migration_group
 entity.migration.destination:
   path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/destination'
   defaults:
diff --git a/migrate_tools.services.yml b/migrate_tools.services.yml
index 9aeebf450ac53a2445034b5c73eb18bcc812baaa..e00705d7473a970f28fe0bda7dbf111e48cb8ef7 100644
--- a/migrate_tools.services.yml
+++ b/migrate_tools.services.yml
@@ -19,7 +19,7 @@ services:
       - { name: event_subscriber }
     arguments:
       - '@event_dispatcher'
-      - '@state'
+      - '@migrate_tools.migrate_tools'
 
   plugin.manager.migrate_shared_config:
     class: Drupal\migrate_tools\MigrateSharedConfigPluginManager
@@ -28,3 +28,7 @@ services:
   migrate_tools.shared_config_include_handler:
     class: Drupal\migrate_tools\MigrateIncludeHandler
     arguments: ['@plugin.manager.migrate_shared_config']
+
+  migrate_tools.migrate_tools:
+    class: Drupal\migrate_tools\MigrateTools
+    arguments: ['@database']
diff --git a/src/Controller/MessageController.php b/src/Controller/MessageController.php
index aeaffa85d8878caa36347014def169bd1e6cdbc9..fd283428427b6cbffe2ef13f30955909b2864c2e 100644
--- a/src/Controller/MessageController.php
+++ b/src/Controller/MessageController.php
@@ -10,11 +10,11 @@ use Drupal\Core\Database\Connection;
 use Drupal\Core\Database\Query\PagerSelectExtender;
 use Drupal\Core\Database\Query\TableSortExtender;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
-use Drupal\migrate\Plugin\MigrateIdMapInterface;
 use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
 use Drupal\migrate_plus\Entity\MigrationGroupInterface;
 use Drupal\migrate_plus\Entity\MigrationInterface as MigratePlusMigrationInterface;
+use Drupal\migrate_tools\MigrateTools;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -92,6 +92,7 @@ class MessageController extends ControllerBase {
     $build = [];
     $rows = [];
     $classes = static::getLogLevelClassMap();
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration_plugin */
     $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
     $source_id_field_names = array_keys($migration_plugin->getSourcePlugin()->getIds());
     $column_number = 1;
@@ -111,6 +112,10 @@ class MessageController extends ControllerBase {
       'data' => $this->t('Message'),
       'field' => 'message',
     ];
+    $header[] = [
+      'data' => $this->t('Destination ID'),
+      'field' => 'destid',
+    ];
     $header[] = [
       'data' => $this->t('Status'),
       'field' => 'source_row_status',
@@ -132,27 +137,38 @@ class MessageController extends ControllerBase {
         ->execute();
     }
 
-    $status_strings = [
-      MigrateIdMapInterface::STATUS_IMPORTED => $this->t('Imported'),
-      MigrateIdMapInterface::STATUS_NEEDS_UPDATE => $this->t('Pending'),
-      MigrateIdMapInterface::STATUS_IGNORED => $this->t('Ignored'),
-      MigrateIdMapInterface::STATUS_FAILED => $this->t('Failed'),
-    ];
+    $level_mapping = MigrateTools::getLogLevelLabelMapping();
+    $status_mapping = MigrateTools::getStatusLevelLabelMapping();
 
     foreach ($result as $message_row) {
       $column_number = 1;
+      $data = [];
       foreach ($source_id_field_names as $source_id_field_name) {
         $column_name = 'sourceid' . $column_number++;
-        $row[$column_name] = $message_row->$column_name;
+        $data[$column_name] = $message_row->$column_name;
       }
-      $row['level'] = $message_row->level;
-      $row['message'] = $message_row->message;
-      $row['status'] = $status_strings[$message_row->source_row_status];
-      $row['class'] = [
-        Html::getClass('migrate-message-' . $message_row->level),
-        $classes[$message_row->level],
+      $data['level'] = $level_mapping[$message_row->level] ?: $message_row->level;
+      $data['message'] = $message_row->message;
+      $column_number = 1;
+      foreach ($migration_plugin->getDestinationPlugin()->getIds() as $dest_id_field_name => $dest_id_schema) {
+        $column_name = 'destid' . $column_number++;
+        $data['destid']['data'][] = $message_row->$column_name;
+        $data['destid']['#destination_fields'][$dest_id_field_name] =
+        $data['destid']['#destination_fields'][$column_name] = $message_row->$column_name;
+      }
+      $destid = array_filter($data['destid']['data']);
+      $data['destid']['data'] = [
+        '#markup' => $destid ? implode(MigrateTools::DEFAULT_ID_LIST_DELIMITER, $data['destid']['data']) : '',
+      ];
+
+      $data['status'] = $status_mapping[$message_row->source_row_status];
+      $rows[] = [
+        'class' => [
+          Html::getClass('migrate-message-' . $message_row->level),
+          $classes[$message_row->level],
+        ],
+        'data' => $data,
       ];
-      $rows[] = $row;
     }
 
     $build['message_table'] = [
diff --git a/src/Controller/MigrationController.php b/src/Controller/MigrationController.php
index 37bc7e86e9c6b09945722242e19477d93e0588b6..4ab1e5318a75666b71368abb8c2455a28ceb90c8 100644
--- a/src/Controller/MigrationController.php
+++ b/src/Controller/MigrationController.php
@@ -159,32 +159,6 @@ class MigrationController extends ControllerBase implements ContainerInjectionIn
     return $build;
   }
 
-  /**
-   * Run a migration.
-   *
-   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
-   *   The migration group.
-   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
-   *   The $migration.
-   *
-   * @return \Symfony\Component\HttpFoundation\RedirectResponse|null
-   *   A redirect response if the batch is progressive. Else no return value.
-   */
-  public function run(MigrationGroupInterface $migration_group, MigrationInterface $migration): ?RedirectResponse {
-    $migrateMessage = new MigrateMessage();
-    $options = [];
-
-    $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
-    $executable = new MigrateBatchExecutable($migration_plugin, $migrateMessage, $options);
-    $executable->batchImport();
-
-    $route_parameters = [
-      'migration_group' => $migration_group,
-      'migration' => $migration->id(),
-    ];
-    return batch_process(Url::fromRoute('entity.migration.process', $route_parameters));
-  }
-
   /**
    * Display process information of a migration entity.
    *
@@ -251,15 +225,6 @@ class MigrationController extends ControllerBase implements ContainerInjectionIn
       '#empty' => $this->t('No process defined.'),
     ];
 
-    $build['process']['run'] = [
-      '#type' => 'link',
-      '#title' => $this->t('Run'),
-      '#url' => Url::fromRoute('entity.migration.process.run', [
-        'migration_group' => $migration_group->id(),
-        'migration' => $migration->id(),
-      ]),
-    ];
-
     return $build;
   }
 
diff --git a/src/Drush/Commands/MigrateToolsCommands.php b/src/Drush/Commands/MigrateToolsCommands.php
index dc4c5ac32d62702614d9cb0b1842d561d4671e99..460f2fcf1f97005fa6b08045298ba369c32d7da9 100644
--- a/src/Drush/Commands/MigrateToolsCommands.php
+++ b/src/Drush/Commands/MigrateToolsCommands.php
@@ -601,7 +601,7 @@ class MigrateToolsCommands extends DrushCommands {
 
     // If any rollbacks failed, throw an exception to generate exit status.
     if ($has_failure) {
-      $error_message = \dt('!name migration failed.', ['!name' => $errored_migration_id]);
+      $error_message = \dt('@name migration failed.', ['@name' => $errored_migration_id]);
       if ($options['continue-on-failure']) {
         $this->logger()->error($error_message);
       }
@@ -799,6 +799,7 @@ class MigrateToolsCommands extends DrushCommands {
     }
     $table = [];
 
+    $level_mapping = MigrateTools::getLogLevelLabelMapping();
     foreach ($map->getMessages() as $row) {
       unset($row->msgid);
       $array_row = (array) $row;
@@ -815,8 +816,9 @@ class MigrateToolsCommands extends DrushCommands {
           $destination_ids[$name] = $item;
         }
       }
-      $array_row['source_ids'] = implode(':', $source_ids);
-      $array_row['destination_ids'] = implode(':', $destination_ids);
+      $array_row['level'] = $level_mapping[$array_row['level']];
+      $array_row['source_ids'] = implode(MigrateTools::DEFAULT_ID_LIST_DELIMITER, $source_ids);
+      $array_row['destination_ids'] = array_filter($destination_ids) ? implode(MigrateTools::DEFAULT_ID_LIST_DELIMITER, $destination_ids) : '';
       $table[] = $array_row;
     }
     if (empty($table)) {
@@ -1104,12 +1106,12 @@ class MigrateToolsCommands extends DrushCommands {
     $executed_migrations += [$migration_id => $migration_id];
     if ($count = $executable->getFailedCount()) {
       $error_message = \dt(
-        '!name Migration - !count failed.',
-        ['!name' => $migration_id, '!count' => $count]
+        '@name Migration - @count failed.',
+        ['@name' => $migration_id, '@count' => $count]
       );
     }
     elseif ($result == MigrationInterface::RESULT_FAILED) {
-      $error_message = \dt('!name migration failed.', ['!name' => $migration_id]);
+      $error_message = \dt('@name migration failed.', ['@name' => $migration_id]);
     }
     else {
       $error_message = '';
diff --git a/src/EventSubscriber/MigrationImportSync.php b/src/EventSubscriber/MigrationImportSync.php
index b7e5288b1912990ef2fcec9dd56c46e6d92ced8a..79cd9c4671522dc65845e2c50ce8301821f3ae69 100644
--- a/src/EventSubscriber/MigrationImportSync.php
+++ b/src/EventSubscriber/MigrationImportSync.php
@@ -11,6 +11,7 @@ use Drupal\migrate\Event\MigrateRollbackEvent;
 use Drupal\migrate\Event\MigrateRowDeleteEvent;
 use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate_plus\Event\MigrateEvents as MigratePlusEvents;
+use Drupal\migrate_tools\MigrateTools;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
@@ -25,26 +26,17 @@ class MigrationImportSync implements EventSubscriberInterface {
    * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
    */
   protected EventDispatcherInterface $dispatcher;
-
-  /**
-   * The state key/value store.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  protected StateInterface $state;
+  protected MigrateTools $migrateTools;
 
   /**
    * MigrationImportSync constructor.
    *
    * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
    *   The event dispatcher.
-   * @param Drupal\Core\State\StateInterface $state
-   *   The Key/Value Store to use for tracking synced source rows.
    */
-  public function __construct(EventDispatcherInterface $dispatcher, StateInterface $state) {
+  public function __construct(EventDispatcherInterface $dispatcher, MigrateTools $migrateTools) {
     $this->dispatcher = $dispatcher;
-    $this->state = $state;
-    $this->state->set('migrate_tools_sync', []);
+    $this->migrateTools = $migrateTools;
   }
 
   /**
@@ -53,6 +45,7 @@ class MigrationImportSync implements EventSubscriberInterface {
   public static function getSubscribedEvents(): array {
     $events = [];
     $events[MigrateEvents::PRE_IMPORT][] = ['sync'];
+    $events[MigrateEvents::POST_IMPORT][] = ['cleanSyncData'];
     return $events;
   }
 
@@ -61,10 +54,18 @@ class MigrationImportSync implements EventSubscriberInterface {
    *
    * @param \Drupal\migrate\Event\MigrateImportEvent $event
    *   The migration import event.
+   *
+   * @throws \Exception
    */
   public function sync(MigrateImportEvent $event): void {
     $migration = $event->getMigration();
     if (!empty($migration->syncSource)) {
+      $migrationId = $migration->getPluginId();
+      // Clear Sync IDs for this migration before starting preparing rows.
+      $this->migrateTools->clearSyncSourceIds($migrationId);
+      // Activate the syncing state for this migration, so
+      // migrate_tools_migrate_prepare_row() can record all IDs.
+      $this->migrateTools->setMigrationSyncingState($migrationId, TRUE);
 
       // Loop through the source to register existing source ids.
       // @see migrate_tools_migrate_prepare_row().
@@ -76,7 +77,12 @@ class MigrationImportSync implements EventSubscriberInterface {
         $source->next();
       }
 
-      $source_id_values = $this->state->get('migrate_tools_sync', []);
+      // Deactivate the syncing state for this migration, so
+      // migrate_tools_migrate_prepare_row() does not record any further IDs
+      // during the actual migration process.
+      $this->migrateTools->setMigrationSyncingState($migrationId, FALSE);
+
+      $source_id_values = $this->migrateTools->getSyncSourceIds($migrationId);
 
       $id_map = $migration->getIdMap();
       $id_map->rewind();
@@ -112,6 +118,18 @@ class MigrationImportSync implements EventSubscriberInterface {
     }
   }
 
+  /**
+   * Cleans Sync data after a migration is complete.
+   *
+   * @param \Drupal\migrate\Event\MigrateImportEvent $event
+   *   The migration import event.
+   */
+  public function cleanSyncData(MigrateImportEvent $event): void {
+    $migration = $event->getMigration();
+    $migrationId = $migration->getPluginId();
+    $this->migrateTools->clearSyncSourceIds($migrationId);
+  }
+
   /**
    * Dispatches MigrateRowDeleteEvent event.
    *
diff --git a/src/Form/MigrationExecuteForm.php b/src/Form/MigrationExecuteForm.php
index 0af2e205faf43883d9863589b457756d626963c3..be6f0f1bc283091867289e5ca965a5c3834419b7 100644
--- a/src/Form/MigrationExecuteForm.php
+++ b/src/Form/MigrationExecuteForm.php
@@ -202,7 +202,7 @@ class MigrationExecuteForm extends FormBase {
             $this->messenger()->addStatus($this->t('Rollback completed', ['@id' => $migration_id]));
           }
           else {
-            $this->messenger()->addError($this->t('Rollback of !name migration failed.', ['!name' => $migration_id]));
+            $this->messenger()->addError($this->t('Rollback of @name migration failed.', ['@name' => $migration_id]));
           }
           break;
 
diff --git a/src/MigrateTools.php b/src/MigrateTools.php
index 2e1f70d024e6efdbc1437d512a7fa47206c9810f..526f3d95d01aaa1f6a66dceac0704089a878bd30 100644
--- a/src/MigrateTools.php
+++ b/src/MigrateTools.php
@@ -4,6 +4,10 @@ declare(strict_types=1);
 
 namespace Drupal\migrate_tools;
 
+use Drupal\Core\Database\Connection;
+use Drupal\migrate\Plugin\MigrateIdMapInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+
 /**
  * Utility functionality for use in migrate_tools.
  */
@@ -14,6 +18,39 @@ class MigrateTools {
    */
   public const DEFAULT_ID_LIST_DELIMITER = ':';
 
+  /**
+   * Maximum number of source IDs to keep in memory before flushing them
+   * to database.
+   */
+  protected const MAX_BUFFERED_SYNC_SOURCE_IDS_ENTRIES = 1000;
+
+  /**
+   * Sync Ids buffered in RAM before flushing them to database.
+   */
+  protected array $bufferedSyncIdsEntries = [];
+
+  /**
+   * Array keeping track of migrations being in the Syncing IDs phase.
+   * Structure: List of `string => bool` where the key is the migration ID and
+   * the value is a boolean indicating whether it is currently syncing.
+   */
+  protected array $syncingMigrations = [];
+
+  /**
+   * Connection to the database.
+   */
+  public Connection $connection;
+
+  /**
+   * MigrateTools constructor.
+   *
+   * @param Connection $connection
+   *   Connection to the database.
+   */
+  public function __construct(Connection $connection) {
+    $this->connection = $connection;
+  }
+
   /**
    * Build the list of specific source IDs to import.
    *
@@ -37,4 +74,150 @@ class MigrateTools {
     return $id_list;
   }
 
+  /**
+   * Clears all SyncSourceIds entries from the database, for given migration.
+   *
+   * @param string $migrationId
+   *   Migration ID.
+   */
+  public function clearSyncSourceIds(string $migrationId): void
+  {
+    $query = $this->connection->delete('migrate_tools_sync_source_ids')
+      ->condition('migration_id', $migrationId);
+    $query->execute();
+  }
+
+  /**
+   * Adds a SyncSourceIds entry to the database, for given migration.
+   *
+   * @param string $migrationId
+   *   Migration ID.
+   * @param array $sourceIds
+   *   A set of SyncSourceIds. Gets serialized to retain its structure.
+   *
+   * @throws \Exception
+   */
+  public function addToSyncSourceIds(string $migrationId, array $sourceIds): void
+  {
+    $this->bufferedSyncIdsEntries[] = [
+      'migration_id' => $migrationId,
+      // Serialize source IDs before saving them to retain their structure.
+      'source_ids' => serialize($sourceIds),
+    ];
+    if (count($this->bufferedSyncIdsEntries) >= static::MAX_BUFFERED_SYNC_SOURCE_IDS_ENTRIES) {
+      $this->flushSyncSourceIdsToDatabase();
+    }
+  }
+
+  /**
+   * Flushes any pending SyncSourceIds to the database.
+   *
+   * @throws \Exception
+   */
+  protected function flushSyncSourceIdsToDatabase(): void {
+    if (empty($this->bufferedSyncIdsEntries)) {
+      // Nothing to flush, do nothing.
+      return;
+    }
+
+    // Batch insert all buffered pending entries.
+    $query = $this->connection->insert('migrate_tools_sync_source_ids')
+      ->fields(['migration_id', 'source_ids']);
+    foreach($this->bufferedSyncIdsEntries as $entry) {
+      $query->values($entry);
+    }
+    $query->execute();
+
+    // Clear buffered pending entries.
+    $this->bufferedSyncIdsEntries = [];
+  }
+
+  /**
+   * Returns all SyncSourceIds from the database, for given migration.
+   *
+   * @param string $migrationId
+   *   Migration ID.
+   *
+   * @return array
+   *   Ids, structured as they were inserted.
+   *
+   * @throws \Exception
+   */
+  public function getSyncSourceIds(string $migrationId): array
+  {
+    // Ensure all data was flushed to database before retrieving all of them.
+    $this->flushSyncSourceIdsToDatabase();
+
+    // Retrieve all IDs.
+    $serializedSourceIds = $this->connection->query(
+        'SELECT source_ids FROM {migrate_tools_sync_source_ids} WHERE migration_id = :mid',
+        [':mid' => $migrationId],
+      )
+      ->fetchCol();
+
+    // Unserialize source IDs to restore their structure.
+    array_walk($serializedSourceIds, static function(&$entry) {
+      $entry = unserialize($entry);
+    });
+
+    return $serializedSourceIds;
+  }
+
+  /**
+   * Sets the syncing state of a migration.
+   *
+   * @param string $migrationId
+   *   Migration ID.
+   * @param bool   $isSyncing
+   *   State to set.
+   */
+  public function setMigrationSyncingState(string $migrationId, bool $isSyncing): void
+  {
+    $this->syncingMigrations[$migrationId] = $isSyncing;
+  }
+
+  /**
+   * Returns the syncing state of a migration.
+   *
+   * @param string $migrationId
+   *   Migration ID.
+   *
+   * @return bool
+   *   Whether the migration is currently syncing its IDs or not.
+   */
+  public function isMigrationSyncing(string $migrationId): bool
+  {
+    return $this->syncingMigrations[$migrationId] ?? FALSE;
+  }
+
+  /**
+   * Returns a mapping of log levels to a human-friendly label.
+   *
+   * @return array
+   *   An array of log level labels.
+   */
+  public static function getLogLevelLabelMapping() {
+    return [
+      MigrationInterface::MESSAGE_ERROR => t('Error'),
+      MigrationInterface::MESSAGE_WARNING => t('Warning'),
+      MigrationInterface::MESSAGE_NOTICE => t('Notice'),
+      MigrationInterface::MESSAGE_INFORMATIONAL => t('Informational'),
+    ];
+  }
+
+  /**
+   * Returns a mapping of status levels to a human-friendly label.
+   *
+   * @return array
+   *   An array of migration status labels.
+   */
+  public static function getStatusLevelLabelMapping() {
+    return [
+      MigrateIdMapInterface::STATUS_IMPORTED => t('Imported'),
+      MigrateIdMapInterface::STATUS_NEEDS_UPDATE => t('Pending'),
+      MigrateIdMapInterface::STATUS_IGNORED => t('Ignored'),
+      MigrateIdMapInterface::STATUS_FAILED => t('Failed'),
+    ];
+  }
+
 }
diff --git a/tests/src/Functional/DrushCommandsTest.php b/tests/src/Functional/DrushCommandsTest.php
index 870d7bbe2cce21c9eb0c621c6e8ed4b12ad18ea5..9421b25e1379926294cb079bc76aebffe6f6e621 100644
--- a/tests/src/Functional/DrushCommandsTest.php
+++ b/tests/src/Functional/DrushCommandsTest.php
@@ -164,7 +164,7 @@ final class DrushCommandsTest extends BrowserTestBase {
     $this->drush('mmsg', ['fruit_terms'], ['format' => 'json']);
     $expected = [
       [
-        'level' => '1',
+        'level' => 'Error',
         'message' => 'You picked a bad one.',
         'source_ids' => 'Apple',
         'destination_ids' => '1',
@@ -174,7 +174,7 @@ final class DrushCommandsTest extends BrowserTestBase {
     $this->drush('mmsg', ['fruit_terms'], ['format' => 'csv']);
     $expected = <<<EOT
 "Source ID(s)","Destination ID(s)",Level,Message
-Apple,1,1,"You picked a bad one."
+Apple,1,Error,"You picked a bad one."
 EOT;
     $this->assertEquals($expected, $this->getOutput());
   }
diff --git a/tests/src/Kernel/DrushTest.php b/tests/src/Kernel/DrushTest.php
index b44a6c3ad3b20fb39231b0d0786fe14ac19a2c12..8c3c4f77a37d35bf01e51f1e6272e272f174e3a0 100644
--- a/tests/src/Kernel/DrushTest.php
+++ b/tests/src/Kernel/DrushTest.php
@@ -77,6 +77,7 @@ namespace Drupal\Tests\migrate_tools\Kernel {
       $this->installEntitySchema('taxonomy_term');
       $this->installEntitySchema('user');
       $this->installSchema('user', ['users_data']);
+      $this->installSchema('migrate_tools', ['migrate_tools_sync_source_ids']);
       $this->migrationPluginManager = $this->container->get('plugin.manager.migration');
       // Handle Drush 10 vs Drush 11 differences.
       $logger_class = class_exists(DrushLoggerManager::class) ? DrushLoggerManager::class : LoggerInterface::class;
diff --git a/tests/src/Kernel/MigrateImportTest.php b/tests/src/Kernel/MigrateImportTest.php
index 777294e8f06851da3f51592550e1921b9922caa9..28bffe07e3c7f8ca00201f829faf9dc548a21868 100644
--- a/tests/src/Kernel/MigrateImportTest.php
+++ b/tests/src/Kernel/MigrateImportTest.php
@@ -30,6 +30,8 @@ final class MigrateImportTest extends MigrateTestBase {
     'system',
   ];
 
+  protected $collectMessages = TRUE;
+
   /**
    * {@inheritdoc}
    */
diff --git a/tests/src/Kernel/MigrateRollbackTest.php b/tests/src/Kernel/MigrateRollbackTest.php
index 87fe38fe114112c8d0a3230f49ba0e09f8f7654c..f749a1c6db677fd39f73c86b94e34df4230a2f5f 100644
--- a/tests/src/Kernel/MigrateRollbackTest.php
+++ b/tests/src/Kernel/MigrateRollbackTest.php
@@ -29,6 +29,8 @@ final class MigrateRollbackTest extends MigrateTestBase {
     'user',
   ];
 
+  protected $collectMessages = TRUE;
+
   /**
    * {@inheritdoc}
    */
diff --git a/tests/src/Unit/MigrateToolsTest.php b/tests/src/Unit/MigrateToolsTest.php
index 4ffe65c9f443912496ea78b3210f99e08703f2c5..b2bbaef6888bd36d896abdf95eb2eac7c2e9d9cf 100644
--- a/tests/src/Unit/MigrateToolsTest.php
+++ b/tests/src/Unit/MigrateToolsTest.php
@@ -26,7 +26,7 @@ final class MigrateToolsTest extends UnitTestCase {
   /**
    * Data provider for testBuildIdList.
    */
-  public function dataProviderIdList(): array {
+  public static function dataProviderIdList(): array {
     $cases = [];
     $cases[] = [
       'options' => [],