Drupal: Queue, Batch and Cron APIs

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

A request that has to talk to a slow remote service, rebuild thousands of records or send hundreds of emails cannot do the work while a visitor waits: the web server times out, PHP runs out of memory, and a failure half way through leaves no trace of what was done. Drupal offers three cooperating APIs for this. Cron (hook_cron) runs small recurring maintenance tasks on a schedule. The Queue API stores units of work so that they can be processed later, at a controlled pace, by a worker plugin that cron or Drush drives. The Batch API splits a long operation that an administrator starts from a form into many short requests with a progress bar. This page explains how each one works inside core, when to choose which, how to write them with the Drupal 11 conventions (PHP attributes, object-oriented hooks, constructor injection), and where the contributed modules Advanced Queue and Ultimate Cron fit.

It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation, change records, the API reference and the core source on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming events. New sites start on Drupal 11.4; Drupal 10 behaviour is mentioned only where a feature arrived in a Drupal 10 minor.

The running example is the Meridian Museum, a fictitious regional science museum. Its module meridian_collection (see Module anatomy and info.yml) keeps collection_object content entities (see Custom entity types) in step with a fictitious collections-management system through an API client service (see Services and dependency injection). This page builds the synchronisation in three layers: a cron hook that plans the work, a queue worker that does it one object at a time, and a batch that lets a curator re-synchronise everything from the admin UI. How cron is scheduled and monitored in production (Automated Cron, system cron, Kubernetes, drush queue:list in a health check) is covered in Cron, queues and maintenance; this page is about the code.

Who works with these APIs

  • A developer writes cron hooks, queue workers, batch operations and the services behind them.

  • A site builder sees the results only indirectly: a curator presses a button and watches a progress bar (Batch), or notices that a change in the collections system appears on the site within a quarter of an hour (Queue and cron).

  • An administrator decides how cron is triggered, which backend stores the queues, how long a worker may run, and watches for queues that do not drain.

Choosing between cron, queue and batch

The three APIs solve different problems, and choosing the wrong one is the most common design error.

Question Cron hook Queue worker Batch operation

Who starts it?

The scheduler (Automated Cron, system cron, Drush)

Anyone who creates an item; cron, Drush or a contributed runner consumes it

A person who submits a form, or code that calls batch_process()

What is it for?

Short recurring tasks: plan work, expire records, rotate files

Many independent units of work that may fail separately and be retried

One long job where the person waits and wants progress feedback

Where does it run?

Inside the cron request or Drush process, as the anonymous user

Inside the cron request, a Drush process, or the end of a request

In a series of web requests (or a Drush child process), as the logged-in user

On failure

The exception is logged; other modules' cron hooks still run

The item stays on the queue and is retried

The batch stops and the finished callback reports the failure

Typical Meridian use

Decide which objects changed and queue them

Synchronise one object from the collections API

"Re-synchronise the whole collection" button for curators

A good rule: cron decides, queues do, batches show. A cron hook should be fast and mostly create queue items; the queue worker does the slow, retryable work; and a batch exists only because a person is waiting for the result. A long hook_cron that does the work itself holds the cron lock for up to fifteen minutes and stops every other module’s scheduled tasks behind it.

hook_cron

Cron is the scheduled entry point. When cron runs (see the triggers in Cron, queues and maintenance), the core cron service (Drupal\Core\Cron) does the following, in this order:

  1. It ignores a client disconnect so that the run is not cut short, and switches the current user to the anonymous user so that cron behaves the same whichever way it was started.

  2. It asks PHP for up to 240 seconds of execution time (Environment::setTimeLimit(240)). The call only helps where the limit may be changed; a stricter web server or FPM limit still wins. Drush runs on the command line, where there is normally no limit.

  3. It takes the cron lock for 900 seconds. If the lock is held, it logs the warning "Attempting to re-run cron while it is already running" and returns without doing anything, which is how overlapping triggers are made harmless.

  4. It invokes every hook_cron implementation, one module after another. An exception thrown by one implementation is caught and logged, and the next module still runs.

  5. It processes the queues that have a cron definition (next sections).

  6. It stores the completion time in the state key system.cron_last and releases the lock.

Since Drupal 11.1 a hook is a method with the #[Hook] attribute on an autowired class in src/Hook (change record 3442349; see Hooks and OOP hooks for the whole mechanism and the 10.x-compatible #[LegacyHook] bridge). The museum’s cron hook does not synchronise anything itself. It checks the setting, rate-limits itself with the State API, and asks a planner service to queue the objects that changed:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\State\StateInterface;
use Drupal\meridian_collection\CollectionSyncPlanner;

/**
 * Plans the collection synchronisation on cron.
 */
final class CollectionCronHooks {

  /**
   * Minimum seconds between two planning runs.
   */
  private const PLAN_INTERVAL = 3600;

  public function __construct(
    private readonly CollectionSyncPlanner $planner,
    private readonly ConfigFactoryInterface $configFactory,
    private readonly StateInterface $state,
    private readonly TimeInterface $time,
  ) {}

  /**
   * Implements hook_cron().
   */
  #[Hook('cron')]
  public function cron(): void {
    if (!$this->configFactory->get('meridian_collection.settings')->get('sync_enabled')) {
      return;
    }
    $now = $this->time->getRequestTime();
    $last = (int) $this->state->get('meridian_collection.last_plan', 0);
    if ($now - $last < self::PLAN_INTERVAL) {
      return;
    }
    // Record the attempt first: a failing API must not be hammered by every
    // cron run, and the queue worker has its own retry logic.
    $this->state->set('meridian_collection.last_plan', $now);
    $this->planner->enqueueChangedSince($last);
  }

}

The class needs no registration: core finds attribute classes in src/Hook and autowires their constructor, and TimeInterface, StateInterface and ConfigFactoryInterface resolve through the core service aliases. The older style, a meridian_collection_cron() function in meridian_collection.module, still works and is what older books show.

Rules for a cron hook

  • Keep it short. Everything shares one lock and one time budget. Queue the work instead of doing it.

  • Never assume a user. Cron runs as the anonymous user, so entity access checks, \Drupal::currentUser() and the language negotiated for a web request do not mean what they do in a page request.

  • Make it idempotent. A cron run can be repeated after a crash, and two triggers can race at the edge of the lock timeout.

  • Do not rely on timing. Cron runs when it is scheduled, not when a person expects; if an action must happen at an exact minute, a contributed scheduler or a system-level job is the right tool, not hook_cron.

  • Record state in State, not configuration. Timestamps like the one above are environment data and must not appear in exported configuration (see Configuration API and schema).

  • Log with a channel. The logger.channel.cron channel records the "Cron run completed" message; the module’s own channel keeps its messages filterable in the log (see Logging, Status report and troubleshooting).

The system.cron configuration has a logging key. When it is enabled, cron logs the start and the duration of every module’s hook_cron, which is the quickest way to find the module that makes cron slow.

The Queue API

A queue is a named list of work items. Each item carries a data payload and is stored by a queue backend. The API is the same whatever the backend:

Method (QueueInterface) Purpose

createQueue()

Prepares the storage. Idempotent; cron calls it before processing a queue.

createItem($data)

Adds an item. The payload is any serialisable value. Returns the item ID, or FALSE on failure.

numberOfItems()

The number of items still in the queue (claimed items included).

claimItem($lease_time)

Takes the oldest available item and locks it for the lease time in seconds. Returns FALSE if nothing is available.

deleteItem($item)

Removes a processed item. Needs the full object returned by claimItem().

releaseItem($item)

Makes a claimed item available again immediately.

deleteQueue()

Removes the queue and all its items.

You obtain a queue from the queue service, Drupal\Core\Queue\QueueFactory, which is autowirable by its class name:

$queue = $this->queueFactory->get('meridian_collection_sync');
$queue->createItem(['accession_number' => 'MM-1987-0042']);

Source: Queue API

Several properties of the API drive correct designs:

  • At least once, not exactly once. If a consumer dies after doing the work but before deleteItem(), the item reappears when its lease expires and is processed again. A worker must be idempotent: the Meridian worker below looks the object up by accession number and updates it, so processing the same item twice leaves the same result.

  • Items are not unique. createItem() never checks for duplicates. If planning runs twice before the worker runs, the same accession number is queued twice. Idempotent workers make that harmless; a cheap guard in the producer reduces the waste.

  • Payloads are serialised. The database backend stores data with PHP serialisation. Queue identifiers and small scalar values, never entity objects or services: an entity may be deleted or changed by the time the worker runs, and a serialised object can fail to unserialize after a code change. The worker should load the fresh entity by ID.

  • Order is best effort. The database queue returns the oldest item first (ordered by creation time and item ID), but a released or retried item goes back into that order, and non-reliable backends do not promise order at all.

Backends and settings

The default backend, queue.database, stores items in the queue table (columns item_id, name, data, expire and created). It implements ReliableQueueInterface (order and at-least-once delivery are guaranteed) and DelayableQueueInterface (an item can be delayed). Core also ships a memory backend and the special Batch and BatchMemory queues used by the Batch API. Contributed backends trade guarantees for throughput: the Redis module provides queue.redis (non-reliable) and queue.redis_reliable.

QueueFactory::get($name, $reliable = FALSE) chooses the backend from settings.php. The service for a queue is, in order of precedence, the entry queue_reliable_service_NAME (only when a reliable queue is requested), the entry queue_service_NAME, the global queue_default, and finally queue.database:

// sites/default/settings.php (excerpt)
// Use the database for every queue unless overridden below.
$settings['queue_default'] = 'queue.database';

// Move only the high-volume Meridian sync queue to a Redis queue that is
// still reliable (ordered, at least once).
$settings['queue_service_meridian_collection_sync'] = 'queue.redis_reliable';

Source: Redis module

The Redis module’s own documentation recommends a separate Redis instance for queue data, because items are not cache and must survive a restart. See settings.php and environments for how such overrides are kept per environment and Performance and caching for the Redis cache backend.

The lifecycle of a queue item

stateDiagram-v2 [*] --> Queued: createItem() Queued --> Claimed: claimItem(lease) Claimed --> [*]: processItem() returns, deleteItem() Claimed --> Queued: RequeueException or SuspendQueueException, releaseItem() Claimed --> Delayed: DelayedRequeueException, delayItem() Delayed --> Queued: lease expires Claimed --> Queued: any other exception, lease expires Claimed --> Queued: worker dies, lease expires

An item is never "failed" in the queue: it is either gone, waiting, or locked until a time. A permanently bad item (for example, an accession number the collections API no longer knows) therefore comes back for ever unless the worker removes it. The worker below handles that case on purpose.

QueueWorker plugins

A queue is only a list. The code that processes an item is a queue worker, a plugin of type QueueWorker (see Plugin API and PHP attributes for plugin discovery). The plugin lives in src/Plugin/QueueWorker, carries the QueueWorker attribute from Drupal\Core\Queue\Attribute, and implements processItem($data), usually by extending QueueWorkerBase. The attribute has four arguments:

Argument Meaning

id

The plugin ID. By convention it equals the machine name of the queue, so that cron can find the queue for a worker (QueueFactory::get($plugin_id)).

title

A TranslatableMarkup label, shown by Drush and administration tools.

cron

Optional array with the single key time: the seconds cron may spend on this queue per run. Without the cron key cron never touches the queue. A missing or non-positive time is replaced by QueueWorkerManagerInterface::DEFAULT_QUEUE_CRON_TIME, which is 15 seconds in the 11.x source.

deriver

Optional deriver class that generates several workers from one class (for example one per bundle).

Plugins use PHP attributes since the 10.x line moved core’s plugin types away from annotations (10.2). The @QueueWorker annotation (Drupal\Core\Annotation\QueueWorker) still works for now and appears in all tutorials written before 2024, but new code uses the attribute.

The shared importer service

The worker, the Drush command and the batch all need the same operation, "make the site’s copy of this object match the collections system". That belongs in a service, not in the worker class. First the contract of the API client, which is the service the form in Form API already uses, and the exceptions it throws:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

/**
 * Client for the museum's collections-management API.
 */
interface CollectionApiClientInterface {

  /**
   * Lists accession numbers changed since a Unix timestamp.
   *
   * @return string[]
   *   Accession numbers.
   *
   * @throws \Drupal\meridian_collection\Exception\CollectionApiUnavailableException
   */
  public function listChangedSince(int $timestamp): array;

  /**
   * Fetches one object record.
   *
   * @return array<string, mixed>
   *   Keys: title, period, material, dimensions, on_display.
   *
   * @throws \Drupal\meridian_collection\Exception\CollectionApiUnavailableException
   *   When the API is down or timing out.
   * @throws \Drupal\meridian_collection\Exception\CollectionApiRateLimitedException
   *   When the API answers HTTP 429.
   * @throws \Drupal\meridian_collection\Exception\CollectionObjectNotFoundException
   *   When the object no longer exists upstream.
   */
  public function fetchObject(string $accessionNumber): array;

}
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Exception;

/**
 * The collections API did not answer.
 */
class CollectionApiUnavailableException extends \RuntimeException {}
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Exception;

/**
 * The collections API asked the client to slow down.
 */
class CollectionApiRateLimitedException extends \RuntimeException {

  public function __construct(
    string $message,
    private readonly int $retryAfter = 60,
    ?\Throwable $previous = NULL,
  ) {
    parent::__construct($message, 0, $previous);
  }

  /**
   * Seconds the API asked the client to wait.
   */
  public function getRetryAfter(): int {
    return $this->retryAfter;
  }

}

(CollectionObjectNotFoundException is an empty \RuntimeException subclass in the same namespace.) The importer wraps the client and the entity storage. It is the only class that knows how a record maps to fields:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Entity\EntityTypeManagerInterface;

/**
 * Makes one collection_object entity match the collections API.
 */
final class CollectionObjectImporter {

  public function __construct(
    private readonly CollectionApiClientInterface $apiClient,
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  /**
   * Fetches an object and creates or updates its entity.
   *
   * @return bool
   *   TRUE if an entity was created or changed, FALSE if it was up to date.
   */
  public function import(string $accessionNumber): bool {
    $record = $this->apiClient->fetchObject($accessionNumber);

    $storage = $this->entityTypeManager->getStorage('collection_object');
    // loadByProperties() performs no access check, which is right here: a
    // synchronisation is a system process, not an action of the current user.
    $existing = $storage->loadByProperties(['accession_number' => $accessionNumber]);
    $entity = $existing ? reset($existing) : $storage->create(['accession_number' => $accessionNumber]);

    $values = [
      'title' => $record['title'],
      'period' => $record['period'] ?? NULL,
      'material' => $record['material'] ?? NULL,
      'dimensions' => $record['dimensions'] ?? NULL,
      'on_display' => (bool) ($record['on_display'] ?? FALSE),
    ];
    $changed = $entity->isNew();
    foreach ($values as $field => $value) {
      if ($entity->get($field)->value !== $value) {
        $entity->set($field, $value);
        $changed = TRUE;
      }
    }
    if ($changed) {
      $entity->save();
    }
    return $changed;
  }

}

Source: Entity API

The services are declared with autowiring; _defaults applies to every service in the file. The CollectionApiClientInterface alias was added in Services and dependency injection:

# meridian_collection.services.yml (excerpt)
services:
  _defaults:
    autowire: true

  meridian_collection.object_importer:
    class: Drupal\meridian_collection\CollectionObjectImporter
  Drupal\meridian_collection\CollectionObjectImporter: '@meridian_collection.object_importer'

  meridian_collection.sync_planner:
    class: Drupal\meridian_collection\CollectionSyncPlanner
  Drupal\meridian_collection\CollectionSyncPlanner: '@meridian_collection.sync_planner'

The worker

The worker translates the importer’s outcomes into the queue’s vocabulary. This translation, not the fetching, is the part that is specific to queues:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\QueueWorker;

use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Queue\Attribute\QueueWorker;
use Drupal\Core\Queue\DelayedRequeueException;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\Queue\SuspendQueueException;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\CollectionObjectImporter;
use Drupal\meridian_collection\Exception\CollectionApiRateLimitedException;
use Drupal\meridian_collection\Exception\CollectionApiUnavailableException;
use Drupal\meridian_collection\Exception\CollectionObjectNotFoundException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Synchronises one collection object from the collections-management API.
 */
#[QueueWorker(
  id: 'meridian_collection_sync',
  title: new TranslatableMarkup('Collection object sync'),
  cron: ['time' => 60],
)]
final class CollectionObjectSyncWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    private readonly CollectionObjectImporter $importer,
    private readonly LoggerInterface $logger,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('meridian_collection.object_importer'),
      $container->get('logger.channel.meridian_collection'),
    );
  }

  public function processItem($data): void {
    $accession = is_array($data) ? ($data['accession_number'] ?? NULL) : NULL;
    if (!is_string($accession) || $accession === '') {
      // A malformed item can never succeed. Returning normally deletes it,
      // which is exactly what a poison item needs.
      $this->logger->error('Dropping a malformed sync item.');
      return;
    }

    try {
      $this->importer->import($accession);
    }
    catch (CollectionObjectNotFoundException) {
      // Gone upstream: nothing to retry. Delete the item by returning.
      $this->logger->warning('Object @accession no longer exists upstream.', ['@accession' => $accession]);
    }
    catch (CollectionApiRateLimitedException $e) {
      // Keep this item locked for as long as the API asked, but let the rest
      // of the queue go on.
      throw new DelayedRequeueException($e->getRetryAfter(), $e->getMessage(), 0, $e);
    }
    catch (CollectionApiUnavailableException $e) {
      // The next items would fail in the same way: stop this queue for this
      // cron run. The item is released and tried again on the next run.
      throw new SuspendQueueException($e->getMessage(), 0, $e);
    }
  }

}

Dependency injection in a plugin goes through ContainerFactoryPluginInterface::create(), the plugin counterpart of the service constructor; QueueWorkerBase itself extends PluginBase, whose constructor takes the three plugin arguments. Static \Drupal:: calls inside the class are not needed.

What each outcome means

Drupal\Core\Cron (through QueueProcessTrait::processItem()) claims one item, calls processItem($item->data) and then reacts to what the worker did:

Worker behaviour Effect on the item and the queue

Returns normally

deleteItem(): the item is done.

Throws RequeueException

releaseItem(): the item is available again at once, and the queue continues. Introduced with Drupal 8.1 (change record 2667356) for work that cannot finish yet.

Throws DelayedRequeueException($delay)

The item stays locked. With a queue that implements DelayableQueueInterface (the database queue does) the lock is set to $delay seconds from now; otherwise the remaining lease is used. The rest of the queue continues. Introduced in Drupal 9.1 (change record 3127239).

Throws SuspendQueueException

The item is released and the whole queue is skipped for this run. The exception may carry a delay in seconds (the fourth constructor argument) as a hint for when to resume.

Throws any other \Throwable

The exception is logged through Error::logException() and the item is left as it is: the lease keeps it locked until it expires, then it is claimed again.

The behaviour of the SuspendQueueException delay is easy to misread. When a suspended exception has a delay below suspendMaximumWait (30 seconds, the queue.config container parameter), cron puts the queue back at the end of its processing stack with that start time and may come back to it in the same run, after the other queues. A delay of 30 seconds or more, or no delay, means the queue is not revisited until the next cron run. The delay argument and its handling are described in the documentation of the SuspendQueueException class and of Drupal\Core\Cron in the API reference.

How cron processes the queue

flowchart TD A["cron.run()"] --> B{"cron lock free?"} B -- no --> Z["log warning and stop"] B -- yes --> C["invoke every hook_cron"] C --> D["for each worker with a cron definition"] D --> E["createQueue(), create worker"] E --> F{"time budget left?"} F -- no --> N["next queue"] F -- yes --> G["claimItem(cron time)"] G -- nothing --> N G -- item --> H["worker.processItem()"] H -- ok --> I["deleteItem()"] H -- RequeueException --> J["releaseItem()"] H -- DelayedRequeueException --> K["delayItem()"] H -- SuspendQueueException --> L["releaseItem() and skip queue"] H -- other exception --> M["log and keep item locked"] I --> F J --> F K --> F M --> F L --> N N --> D

Three details of this loop matter in practice:

  • The time value does two jobs. processQueue() keeps claiming items until time seconds have passed, and the same value is passed to claimItem() as the lease time. A queue declared with cron: ['time' => 60] is worked on for at most a minute per cron run, and each claimed item is locked for 60 seconds. An item that takes longer than that to process becomes available to another process and can be processed twice. Keep one item far below the value.

  • The time is checked between items, not during one. A single slow item can overrun the budget, and the 240 seconds that cron asks PHP for are shared by all hook_cron implementations and all queues. In a web-triggered cron several queues with 60-second budgets can exceed it; a Drush-triggered cron has no such limit.

  • Queues do not run in parallel. Cron processes one queue at a time inside one process and holds the cron lock. A backlog that grows faster than cron drains it needs a dedicated, separately scheduled drush queue:run (below), or a different runner.

A worker that depends on the current user, language or request must not. Cron runs as the anonymous user; if the sync needs particular permissions or content language, set them explicitly in the service.

Hooks that change the queue definitions

hook_queue_info_alter(&$queues) runs after the plugin definitions are collected and before cron uses them. It is the supported place to change another module’s cron time or to remove a worker from cron without patching the module:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Adjusts queue definitions.
 */
final class CollectionQueueHooks {

  /**
   * Implements hook_queue_info_alter().
   */
  #[Hook('queue_info_alter')]
  public function queueInfoAlter(array &$queues): void {
    // On this site the Meridian sync is drained by a dedicated scheduled
    // "drush queue:run", so cron must leave it alone.
    unset($queues['meridian_collection_sync']['cron']);
  }

}

Removing the cron key is also the way to run a queue only from Drush: a worker without a cron definition is ignored by the cron service but stays available to drush queue:run.

Planning the work: the enqueue side

The CollectionSyncPlanner service is the producer. It asks the API what changed and creates one item per distinct accession number, so the API’s answer cannot queue the same object twice in one run:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Queue\QueueFactory;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Creates queue items for collection objects that changed upstream.
 */
final class CollectionSyncPlanner {

  public const QUEUE = 'meridian_collection_sync';

  public function __construct(
    private readonly CollectionApiClientInterface $apiClient,
    private readonly QueueFactory $queueFactory,
    #[Autowire(service: 'logger.channel.meridian_collection')]
    private readonly LoggerInterface $logger,
  ) {}

  /**
   * Queues every object changed since the given timestamp.
   *
   * @return int
   *   The number of items created.
   */
  public function enqueueChangedSince(int $timestamp): int {
    try {
      $accessionNumbers = $this->apiClient->listChangedSince($timestamp);
    }
    catch (\RuntimeException $e) {
      // The API is down: planning is skipped, and the next cron run plans
      // again from the same timestamp because the caller keeps it.
      $this->logger->warning('Cannot plan the sync: @message', ['@message' => $e->getMessage()]);
      return 0;
    }

    $queue = $this->queueFactory->get(self::QUEUE);
    $queue->createQueue();
    $count = 0;
    foreach (array_unique($accessionNumbers) as $accessionNumber) {
      $queue->createItem(['accession_number' => $accessionNumber]);
      $count++;
    }
    $this->logger->info('Queued @count objects for synchronisation.', ['@count' => $count]);
    return $count;
  }

}

Source: Queue API

Note that a planner that creates a very large number of items in one run, for instance a full re-import of 200,000 objects, makes that request itself slow. Create items in chunks (for example one item per hundred accession numbers, with the worker looping over its chunk), or put the work inside a batch or a Drush command.

The Drush command

meridian:sync plans the work from the command line, so that an operator can start a full synchronisation after an outage. Drush 13 and 14 discover command classes ending in Commands in src/Drush/Commands of a module; the AutowireTrait from Drush injects services by constructor type hint. (Drush 13.7 recommends writing new commands as Symfony Console commands and treats attribute-annotated commands as the older style; both work in Drush 13 and 14. Commands are explained in Drush.)

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Drush\Commands;

use Drupal\meridian_collection\CollectionSyncPlanner;
use Drush\Attributes as CLI;
use Drush\Commands\AutowireTrait;

/**
 * Drush commands for the Meridian collection.
 */
final class MeridianSyncCommands {

  use AutowireTrait;

  public function __construct(
    private readonly CollectionSyncPlanner $planner,
  ) {}

  /**
   * Queues collection objects for synchronisation.
   */
  #[CLI\Command(name: 'meridian:sync', aliases: ['msync'])]
  #[CLI\Option(name: 'since', description: 'Unix timestamp; objects changed after it are queued. 0 queues everything.')]
  #[CLI\Usage(name: 'drush meridian:sync --since=0', description: 'Queue the whole collection, then run: drush queue:run meridian_collection_sync')]
  public function sync(array $options = ['since' => 0]): string {
    $count = $this->planner->enqueueChangedSince((int) $options['since']);
    return sprintf('Queued %d objects. Process them with: drush queue:run meridian_collection_sync', $count);
  }

}

Running a queue outside cron

The Drush commands for queues are queue:list (name, number of items and worker class of every queue), queue:run and queue:delete:

# What is waiting?
ddev drush queue:list --fields=queue,items

# Queue the whole collection, then drain it with limits.
ddev drush meridian:sync --since=0
ddev drush queue:run meridian_collection_sync --time-limit=300 --items-limit=1000 --lease-time=120

# Throw away everything in a queue (this deletes work, not just retries it).
ddev drush queue:delete meridian_collection_sync

queue:run takes the queue name and the options --time-limit, --items-limit and --lease-time ("the maximum number of seconds that an item remains claimed"); its alias is queue-run. Without limits it runs until the queue is empty. The Drush process exits when it is done, so a scheduler (a systemd timer, a Kubernetes CronJob) can start it every few minutes. For the scheduling recipes see Cron, queues and maintenance.

A Drush process that runs for a long time keeps services and entity static caches alive. A worker that loads thousands of entities in one process should keep the memory bounded, for instance by using --items-limit and letting the scheduler start a fresh process, instead of relying on a single run to drain a very large queue.

The instant queue runner (Drupal 11.5)

Cron is the default consumer, and a short cron interval is how editors' changes reach the queue’s output "soon". Core change record 3617082 introduces a second consumer in Drupal 11.5.0, which is still in development on the 11.5.x branch at the time of writing: the Drupal\Core\Queue\InstantQueueRunnerInterface service. A module that has just created queue items calls registerQueue($queue_name), and at the end of the request, after the response has been sent (the kernel destruct stage), the runner processes up to maximumItems items per queue (the container parameter instant_queue.config, default 5). It does not guarantee that the processed items are the ones created in this request. The processing code was moved into the shared QueueProcessTrait, which both Cron and the new runner use, so the exception semantics above are the same. On 11.4 the feature is absent and the queue is drained only by cron, Drush or a contributed runner.

The Batch API

The Batch API serves a different situation: a person has pressed a button, the job takes minutes, and they should see a progress bar and a result. The API spreads the job over many short HTTP requests (each one fits comfortably inside the PHP time limit) and stores the state between requests. Core uses it for the database update screen (update.php), configuration import and similar administrative operations. Its reference topic describes its main use as integrating with the Form API, but it also works from page callbacks and scripts.

sequenceDiagram participant U as Curator browser participant F as Resync form participant B as Batch engine participant O as Operation callback U->>F: submit the confirm form F->>B: batch_set(batch definition) B-->>U: redirect to the batch progress page loop until finished is 1 U->>B: request with the batch id B->>O: call with the context O-->>B: sandbox, results, message, finished B-->>U: progress bar update end B->>B: call the finished callback B-->>U: redirect to the target page with a message

The batch definition

A batch is an array, normally built with Drupal\Core\Batch\BatchBuilder (added in Drupal 8.6) and registered with batch_set(). The recognised keys, which are also the BatchBuilder setters, are:

Key (builder method) Meaning

operations (addOperation())

Required. A list of [callback, [arguments]] pairs. The callback is a function name, a Class::method string or a [Class::class, 'method'] array. It must be serialisable: closures and objects are not allowed.

title (setTitle())

The title of the progress page. Defaults to "Processing".

init_message (setInitMessage())

The message shown while the batch initialises.

progress_message (setProgressMessage())

The progress text, with the placeholders @current, @remaining, @total, @percentage, @estimate and @elapsed.

error_message (setErrorMessage())

The message shown when a request of the batch fails.

finished (setFinishCallback())

The callback run when the batch ends, successfully or not.

file (setFile())

The file with the callbacks when they are not autoloaded or in a .module file. Callbacks that are autoloadable static methods need none.

library (setLibraries())

Libraries loaded on the progress page.

url_options (setUrlOptions())

Options for the redirect URLs.

progressive (setProgressive())

TRUE runs the batch over several requests; FALSE finishes it in a single run. BatchBuilder starts with TRUE. For a hand-written array the reference documents FALSE as the default, while batch_process() adds TRUE when the key is missing, so set it explicitly.

queue (setQueue())

Overrides the queue that stores the operations: the Batch queue for progressive batches, BatchMemory otherwise.

The reference topic notes one security obligation: if the title, init_message, progress_message or error_message, or anything an operation writes to $context['results'] or $context['message'], could contain user input, the code must escape it first (Html::escape() or Xss::filter()). The museum’s object titles come from an external system and are therefore escaped, not trusted.

An operation with a sandbox

An operation is called again and again until it reports that it is finished. It receives the arguments of its entry in operations, followed by the $context array passed by reference:

$context key Meaning

$context['sandbox']

Free-form state that survives between the calls of the same operation. It is saved in the database between requests; this is where the cursor lives, instead of the session, which is unsafe if the user keeps browsing in another window.

$context['results']

An array that collects data for the finished callback. It is shared by all operations of the batch set.

$context['message']

The text shown under the progress bar.

$context['finished']

A number between 0 and 1: the completion level of this operation. The engine resets it to 1 before each call, so an operation sets it only when more work remains. When it is 1 (or unset), the engine moves to the next operation.

The museum’s operation collects the list of accession numbers once, then imports ten objects per call. A batch callback is static code that the batch engine calls by name, so it cannot receive injected services; this is one of the few places where \Drupal::service() is the normal way to reach a service. Keep the callbacks thin and put the real work in injectable services, as here:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Batch;

use Drupal\Component\Utility\Html;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Drupal\Core\Url;
use Drupal\meridian_collection\Exception\CollectionApiRateLimitedException;
use Drupal\meridian_collection\Exception\CollectionApiUnavailableException;

/**
 * Batch callbacks for the full collection re-synchronisation.
 */
final class CollectionResyncBatch {

  private const CHUNK = 10;

  /**
   * Batch operation: imports the next CHUNK objects.
   *
   * @param array<string, mixed> $context
   *   The batch context.
   */
  public static function process(array &$context): void {
    $sandbox = &$context['sandbox'];
    if (!isset($sandbox['accession_numbers'])) {
      $sandbox['accession_numbers'] = \Drupal::service('meridian_collection.api_client')->listChangedSince(0);
      $sandbox['total'] = count($sandbox['accession_numbers']);
      $sandbox['done'] = 0;
    }
    $context['results'] += ['imported' => 0, 'failed' => []];

    $importer = \Drupal::service('meridian_collection.object_importer');
    $slice = array_splice($sandbox['accession_numbers'], 0, self::CHUNK);
    foreach ($slice as $accessionNumber) {
      try {
        $importer->import($accessionNumber);
        $context['results']['imported']++;
      }
      catch (CollectionApiUnavailableException | CollectionApiRateLimitedException $e) {
        // Remote trouble: fail the batch rather than recording thousands of
        // identical errors. The finished callback reports it.
        $context['results']['failed'][] = Html::escape($accessionNumber . ': ' . $e->getMessage());
        $sandbox['accession_numbers'] = [];
        $context['finished'] = 1;
        return;
      }
      catch (\Throwable $e) {
        // A bad object must not stop the others.
        $context['results']['failed'][] = Html::escape($accessionNumber . ': ' . $e->getMessage());
      }
      $sandbox['done']++;
      $context['message'] = (string) new TranslatableMarkup('Synchronised @accession (@done of @total)', [
        '@accession' => $accessionNumber,
        '@done' => $sandbox['done'],
        '@total' => $sandbox['total'],
      ]);
    }

    $context['finished'] = $sandbox['total'] > 0 ? $sandbox['done'] / $sandbox['total'] : 1;
  }

  /**
   * Batch finished callback.
   *
   * @param bool $success
   *   TRUE if no fatal PHP error interrupted the batch.
   * @param array<string, mixed> $results
   *   The collected results.
   * @param array<int, mixed> $operations
   *   The operations that were not processed.
   * @param string $elapsed
   *   The elapsed time, formatted.
   */
  public static function finished(bool $success, array $results, array $operations, string $elapsed): RedirectResponse {
    $messenger = \Drupal::messenger();
    if ($success && empty($results['failed'])) {
      $messenger->addStatus(\Drupal::translation()->formatPlural(
        $results['imported'] ?? 0,
        'One object was synchronised in @elapsed.',
        '@count objects were synchronised in @elapsed.',
        ['@elapsed' => $elapsed],
      ));
    }
    else {
      $messenger->addError(new TranslatableMarkup('The synchronisation finished with errors (@count objects failed).', [
        '@count' => count($results['failed'] ?? []),
      ]));
      foreach (array_slice($results['failed'] ?? [], 0, 5) as $failure) {
        $messenger->addWarning($failure);
      }
    }
    return new RedirectResponse(Url::fromRoute('entity.collection_object.collection')->toString());
  }

}

A few points explain the code:

  • The operation is repeated until finished reaches 1. The first call has an empty sandbox and fills it; later calls continue from it. The progress bar uses the number the operation reports, so a wrong or missing finished value either ends the batch after one call or runs it for ever.

  • Do a bounded amount of work per call. Ten objects at about a second each fit comfortably in a request; the engine also starts a new request when enough time has passed since the last one.

  • The finished callback may return a redirect. A RedirectResponse returned by finished is used by the standard batch pipeline and takes precedence over any other target. This is the supported way to choose where the user lands when the batch is started from a form.

  • $success is not "no errors". It only means that no fatal PHP error interrupted the batch. Other failures are reported through results, as the code does.

  • Messages are escaped. The failure strings pass through Html::escape() because they contain data from the remote system.

Starting a batch from a form

A confirm form (see Form API) is the safe way to start a long, state-changing operation: the route is protected by a permission, the confirmation step prevents an accidental start by a link, and the Form API supplies the CSRF token. Inside submitForm() the builder registers the batch; the Form API then calls batch_process() itself:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Form;

use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\meridian_collection\Batch\CollectionResyncBatch;

/**
 * Confirms and starts a full re-synchronisation of the collection.
 */
final class CollectionResyncForm extends ConfirmFormBase {

  public function getFormId(): string {
    return 'meridian_collection_resync_confirm';
  }

  public function getQuestion(): TranslatableMarkup {
    return $this->t('Re-synchronise the whole collection?');
  }

  public function getDescription(): TranslatableMarkup {
    return $this->t('Every object is fetched again from the collections-management system. This can take several minutes.');
  }

  public function getCancelUrl(): Url {
    return new Url('entity.collection_object.collection');
  }

  public function submitForm(array &$form, FormStateInterface $form_state): void {
    $batch = (new BatchBuilder())
      ->setTitle($this->t('Synchronising the collection'))
      ->setInitMessage($this->t('Contacting the collections system.'))
      ->setProgressMessage($this->t('@current of @total steps done, about @estimate remaining.'))
      ->setErrorMessage($this->t('The synchronisation stopped because of an error.'))
      ->addOperation([CollectionResyncBatch::class, 'process'])
      ->setFinishCallback([CollectionResyncBatch::class, 'finished']);

    batch_set($batch->toArray());
    // No batch_process() call: the Form API processes batches set during
    // form submission.
  }

}
# meridian_collection.routing.yml (excerpt)
meridian_collection.resync:
  path: '/admin/content/collection/resync'
  defaults:
    _form: '\Drupal\meridian_collection\Form\CollectionResyncForm'
    _title: 'Re-synchronise the collection'
  requirements:
    _permission: 'administer meridian collection settings'

The progress page is generated by the batch engine at /batch, with the batch ID in the query string. Anyone who starts a batch needs the permission of the form that starts it, and the batch pages themselves are tied to the session that created the batch, so a batch ID copied into another browser does not give access to it.

Batches outside forms

When the batch is started from code that is not a form submit handler, for example a controller, batch_process() must be called with the page to return to; a blank target causes an endless redirect loop in the example of the official overview. The function returns a RedirectResponse for a progressive batch, which the controller returns:

public function resync(): ?RedirectResponse {
  $batch = (new BatchBuilder())
    ->setTitle($this->t('Synchronising the collection'))
    ->addOperation([CollectionResyncBatch::class, 'process']);
  batch_set($batch->toArray());
  return batch_process(Url::fromRoute('entity.collection_object.collection'));
}

Source: batch_process()

A state-changing controller like this one needs a CSRF-protected route (_csrf_token: 'TRUE'), because a plain link to a GET route would let any page on the web start a batch. The confirm form already does that, which is why it is the better default.

Batch or queue?

A batch can store its operations in a queue and a queue can be drained in a batch, so the two are easily confused. Pick the Batch API when the person waiting needs a progress bar and a final report; pick the Queue API when the work is triggered by events or schedules, must survive restarts, and should retry on its own. The museum uses both on purpose: day-to-day changes flow through cron and the queue; the curator’s button runs the batch because the curator needs to know when it is over.

Batch operations are bound to a user’s browser session; closing the tab stops the progress, though the already processed operations stay done. That is why the operation above is idempotent and why a batch should not be the only way to perform a job that must be completed.

In update hooks the situation is different: hook_update_N() and hook_post_update_NAME() do not call batch_set(). They receive a $sandbox argument and set $sandbox['#finished'], and the update runner provides the repetition (see Update API and post-update hooks). Drush commands that need the Batch API call batch_set() and then drush_backend_batch_process(), which runs the batch in child Drush processes; the batch code itself is the same as above. For a Drush-only job, a loop with --items-limit and the queue is usually simpler than a batch.

Contributed alternatives

Core’s queue is deliberately small. Two contributed modules address its main limits; neither is needed for the Meridian module as written, and each one adds a dependency that must be maintained.

Advanced Queue (drupal/advancedqueue, release 8.x-1.7 of 7 September 2026, compatible with Drupal ^10.2 and ^11, covered by the Drupal Security Team) replaces the bare list with queues as configuration entities and jobs as entities. Each job has a type (a JobType plugin that processes it), a state (queued, processing, success, failure), a message and a processing time. Retries can be configured per job type or per job, jobs can be scheduled for a later time, and job lists are available as Views. Queues are processed by cron or by Drush commands such as advancedqueue:queue:process (check drush list on the installed version for the exact names and options). Use it when the museum wants to see, search and retry individual failed syncs from the admin UI, which the core queue cannot show.

Ultimate Cron (drupal/ultimate_cron, 8.x-2.0-beta2 released on 29 October 2026; the project page states that there is currently no supported stable release, so check its status before relying on it) turns each cron hook into an individually scheduled and logged job with its own rules, an overview page, a per-job log and Drush commands to list, start, enable and disable jobs, and it supports Drupal queues. Use it when different hook_cron implementations need different schedules, for instance a nightly cleanup and a five-minute sync, without writing separate system cron entries.

Both modules are optional and sit on top of what this page explains. The core queue, the exception semantics and the cron loop remain the model to understand first.

Testing queues and cron

Kernel tests exercise a worker through the real cron service without a browser. The test replaces the API client with a PHPUnit mock before the importer is created, queues an item, runs cron and asserts that the queue is empty and the entity was created. PHPUnit 11 attributes replace the old docblock annotations (see Automated testing for the test types and phpunit.xml.dist):

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Kernel;

use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\CollectionApiClientInterface;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests that cron drains the collection sync queue.
 */
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class CollectionSyncCronTest extends KernelTestBase {

  protected static $modules = ['system', 'user', 'field', 'text', 'options', 'meridian_collection'];

  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('user');
    $this->installEntitySchema('collection_object');
    $this->installConfig(['system', 'meridian_collection']);
  }

  public function testCronProcessesQueuedObjects(): void {
    $client = $this->createMock(CollectionApiClientInterface::class);
    $client->method('fetchObject')->willReturn([
      'title' => 'Foucault pendulum',
      'period' => 'nineteenth_century',
      'on_display' => TRUE,
    ]);
    $this->container->set('meridian_collection.api_client', $client);

    $queue = $this->container->get('queue')->get('meridian_collection_sync');
    $queue->createQueue();
    $queue->createItem(['accession_number' => 'MM-1987-0042']);

    $this->container->get('cron')->run();

    $this->assertSame(0, $queue->numberOfItems());
    $objects = $this->container->get('entity_type.manager')
      ->getStorage('collection_object')
      ->loadByProperties(['accession_number' => 'MM-1987-0042']);
    $this->assertCount(1, $objects);
  }

}

Source: KernelTestBase

Test the failure paths the same way: a client that throws CollectionApiUnavailableException must leave the item on the queue (numberOfItems() is still 1 after the cron run), and one that throws CollectionObjectNotFoundException must delete it. The batch callbacks are plain static methods, so they can be called directly with a prepared $context array in a Kernel test.

Security

  • A queue is an input channel. Items are created by code, but their payloads can derive from user input (a form field, an imported file, an API response). The worker validates what it reads, as the museum worker does with the accession number, and never trusts a serialised payload to be well-formed.

  • Cron has no user. Do not rely on access checks that assume a logged-in user; the code that must respect permissions loads the entity as a specific account, and code that deliberately ignores access (like the importer) says so in a comment. Entity queries in a worker call accessCheck(FALSE) explicitly; they must call accessCheck() either way.

  • The cron URL is a trigger. Anyone who knows /cron/KEY can start cron. Keep the key out of public places and prefer Drush or a platform scheduler (see Security hardening).

  • Batch and CSRF. Start batches from forms or CSRF-protected routes, and gate them with a permission. Escape any user or remote data placed in titles, messages and results.

  • Secrets stay out of items. Do not put API tokens in a queue payload or in sandbox data: both are stored in the database. The API client reads its key from settings.php or the environment.

Common errors and pitfalls

  • The queue grows and never shrinks. The worker has no cron definition, so only drush queue:run would drain it; or cron is not running at all; or every item throws and is retried. Check drush queue:list and the "Cron run completed" and worker error messages in the log.

  • Items processed twice. The item took longer than the lease. Reduce the work per item, raise time in the attribute, or make the worker idempotent (it should be anyway).

  • Cron "hangs" or takes longer than the interval. A slow hook_cron or queue holds the lock and the next trigger is rejected with "Attempting to re-run cron while it is already running". Enable cron timing logging, move the work to a queue and lower the time budgets.

  • One bad item blocks progress. An exception other than the special ones leaves the item locked, and it is retried endlessly. Catch permanent errors in the worker and return normally, as the museum worker does for a malformed item and an unknown object.

  • SuspendQueueException in a loop. An outage that throws it for every cron run produces a log entry per run. That is correct, but an alert on drush queue:list is a better signal than reading logs.

  • Wrong plugin namespace or ID. The worker must be in Plugin\QueueWorker, and its id must match the queue name used by QueueFactory::get(); otherwise nothing runs it and no error appears. Rebuild caches after adding a worker so that plugin discovery sees it.

  • Closures or objects in a batch. A batch operation must be a named function, a Class::method string or a class and method array, because the definition is stored in the database. A closure fails to serialise.

  • Batch state in the session. Use $context['sandbox'], which is stored in the batch queue, not $_SESSION.

  • $context['finished'] mistakes. Forgetting to set it to a fraction below 1 while work remains ends the operation after one call; setting it to something that never reaches 1 loops for ever.

  • Heavy work in hook_cron. It runs as anonymous with the cron lock held, and a failure is only logged. Queue it.

  • Using \Drupal:: calls in the worker. They work, but make the class untestable. Inject services through create(); use static calls only in batch callbacks and procedural code.

Version notes and corrections of older material

  • Plugin attributes. Core’s plugin types accept PHP attributes since Drupal 10.2, and the QueueWorker attribute is in Drupal\Core\Queue\Attribute. Older books such as Drupal 10 Development Cookbook (2023) and most tutorials show the @QueueWorker annotation in a docblock; it still works on 11.4, but new workers use the attribute and annotations are on a deprecation path; check the plugin API change records for the release that removes them.

  • Object-oriented hooks. Those books implement cron as module_cron() in the .module file. Since Drupal 11.1 the #[Hook('cron')] attribute on a service-like class is the recommended form, and the procedural function is only needed for Drupal 10.x compatibility (with #[LegacyHook] bridging both). hook_queue_info_alter follows the same rule.

  • Static service calls. Examples that fetch the queue with \Drupal::queue('name') inside classes belong to the era before autowiring; classes now receive QueueFactory through the constructor. The static call remains valid in procedural code and tests.

  • Drush. Older material uses drush queue-run; the current name is drush queue:run (the old name remains an alias). Custom Drush commands use attributes and autowiring, not annotations and drush.services.yml, which no longer exists since Drush 13.

  • Batch tutorials. The official "Batch API overview" page is a Drupal 6-era example that is marked as needing review and uses Drupal 7 functions (db_query_range(), node_load(), theme()). The batch definition and the $context contract it describes are still right, but the function calls in its example must not be copied. The finished callback also has a fourth parameter, $elapsed, in current core, which that page does not show. BatchBuilder is the current way to assemble a definition.

  • Queue exceptions. RequeueException (Drupal 8.1) and DelayedRequeueException (Drupal 9.1) are not in books that describe only SuspendQueueException; a modern worker uses all three according to the failure.

  • Instant queue runner. Arriving in Drupal 11.5, it removes the need for a one-minute cron just to flush a few notification items. It is not available on 11.4.

  • Drupal 12. Nothing on this page changes with Drupal 12.0.0 as far as the published change records show: the queue, cron and batch APIs stay, with attribute plugins and object-oriented hooks as the only supported style for new code. Query objects are no longer serialisable in Drupal 12, which reinforces the rule of queueing identifiers, not objects (see Preparing for Drupal 12). The wider list of outdated advice is collected in Where older books and tutorials are outdated.

Where the pieces fit in the Meridian module

Piece File Role

Cron hook

src/Hook/CollectionCronHooks.php

Decides, at most once an hour, to plan

Planner service

src/CollectionSyncPlanner.php

Creates one queue item per changed object

Queue worker

src/Plugin/QueueWorker/CollectionObjectSyncWorker.php

Imports one object and translates failures into queue exceptions

Importer service

src/CollectionObjectImporter.php

Shared by the worker and the batch

Drush command

src/Drush/Commands/MeridianSyncCommands.php

meridian:sync plans a (re)synchronisation on demand

Batch callbacks and confirm form

src/Batch/CollectionResyncBatch.php, src/Form/CollectionResyncForm.php

The curator’s "Re-synchronise the collection" button

Kernel test

tests/src/Kernel/CollectionSyncCronTest.php

Proves that cron drains the queue

Further reading inside this sub-section: Events and subscribers (reacting to entity changes by queueing work), Migrate API (large imports that run as a Drush operation) and Drush.