---
title: "Deadlock-free product indexing in Shopware 6 with a dedicated messenger transport"
description: "Prevent Shopware 6 indexing deadlocks with a dedicated Symfony Messenger transport. Complete config guide with routing and Docker setup."
url: "https://brocksi.net/blog/deadlock-free-product-indexing/"
author: "Björn Meyer"
published: "2026-02-16"
lang: "en"
categories: ["Shopware 6", "Performance", "PHP"]
---

# Deadlock-free product indexing in Shopware 6 with a dedicated messenger transport

When running php bin/console dal:refresh:index on a Shopware 6 installation with multiple message workers, you may encounter the following error:

```
Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction
```

Often followed by:

```
SAVEPOINT DOCTRINE_2 does not exist
```

This guide explains why these deadlocks occur, how Shopware’s message routing works under the hood, and how to configure a dedicated indexer transport that eliminates the multi-worker indexer deadlock class and dramatically reduces deadlocks overall.

## Prerequisites & Tested Versions

This guide was written and tested against the following stack. Class names, middleware behavior, and Doctrine transport internals can change across versions. Verify that these match your setup before applying the configuration.

| Component | Version |
| --- | --- |
| Shopware | 6.6.x |
| Symfony Messenger | 7.x (ships with Shopware 6.6) |
| Database | MariaDB 10.11+ / MySQL 8.0+ |
| Transport | Doctrine (default) |

> If you are running Shopware 6.5 or earlier, the routing\_overwrite config key and some internal class paths may differ. Check your Shopware version’s documentation.

## Understanding the Problem

A typical production Shopware 6 setup runs multiple message workers to process asynchronous tasks like email sending, import/export, and indexing. The default configuration from the [Shopware Docker documentation](https://developer.shopware.com/docs/guides/hosting/installation-updates/docker.html) suggests running multiple worker replicas:

```yaml
worker:
  entrypoint:
    [
      'php',
      'bin/console',
      'messenger:consume',
      'async',
      'low_priority',
      '--limit=300',
      '--memory-limit=512M',
    ]
  deploy:
    replicas: 3
```

This works well for most async tasks. However, when the product indexer runs, either via dal:refresh:index or dal:refresh:index —use-queue, multiple workers end up executing concurrent UPDATE statements on the product table. With tens of thousands of products and parent-child variant relationships, MariaDB/MySQL detects row-level lock conflicts and raises deadlock errors.

The SAVEPOINT does not exist error is a secondary symptom: when MariaDB rolls back a transaction due to a deadlock, Doctrine tries to return to a savepoint that no longer exists.

## Why Deadlocks Occur During Indexing

The ProductIndexer runs over 10 updaters sequentially for each batch of products:

- InheritanceUpdater: resolves parent/child field inheritance
- StockStorage: recalculates stock values
- VariantListingUpdater: updates variant listing configuration
- ChildCountUpdater: counts child products per parent
- CategoryDenormalizer: flattens category assignments
- CheapestPriceUpdater: determines cheapest variant prices
- RatingAverageUpdater: aggregates product ratings
- SearchKeywordUpdater: rebuilds search keywords
- StreamUpdater: evaluates dynamic product groups
- ManyToManyIdFieldUpdater: denormalizes many-to-many IDs

Each updater issues UPDATE statements (often with JOINs) on the product table. When multiple workers process different product batches simultaneously, their lock patterns overlap, especially on parent products that are referenced by multiple variants. This is the classic recipe for database deadlocks.

## The Solution: A Dedicated Indexer Transport

The idea is simple: route all indexer messages to a **separate transport** that is consumed by only **one worker**. This eliminates concurrent writes to the product table from multiple indexer processes, the primary source of deadlocks, while keeping multiple workers available for all other async messages. Note that occasional deadlocks can still occur from other writers (see [How Shopware Handles Deadlocks Internally](#how-shopware-handles-deadlocks-internally)), but the high-frequency multi-worker deadlock pattern is resolved.

[Symfony Messenger](https://symfony.com/doc/current/messenger.html) supports multiple transports, each backed by its own queue. Messages can be routed to specific transports, and workers can be configured to consume from specific transports.

However, Shopware adds its own routing layer on top of Symfony’s, and the Doctrine transport has a database-level detail that must be accounted for. The next two sections explain the pitfalls you need to avoid.

## Pitfall 1: Using the Wrong Routing Mechanism

Your first instinct might be to add routing entries under framework.messenger.routing:

```yaml
# DO NOT do this -- it causes double-routing
framework:
  messenger:
    routing:
      'Shopware\Core\Framework\MessageQueue\AsyncMessageInterface': async
      'Shopware\Core\Framework\MessageQueue\LowPriorityMessageInterface': low_priority
      'Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexingMessage': indexer
```

**This does not work as expected.** Here’s why:

Both EntityIndexingMessage and IterateEntityIndexerMessage implement AsyncMessageInterface:

```php
class EntityIndexingMessage implements AsyncMessageInterface, DeduplicatableMessageInterface
```

Symfony’s SendersLocator iterates over **all matching routing entries**, both the class name and all implemented interfaces. Since EntityIndexingMessage matches both AsyncMessageInterface: async and EntityIndexingMessage: indexer, the message is sent to **both** transports. Your async workers still process it.

From vendor/symfony/messenger/Transport/Sender/SendersLocator.php:

```php
public function getSenders(Envelope $envelope): iterable
{
    // If a TransportNamesStamp exists, use ONLY those transports
    if ($envelope->all(TransportNamesStamp::class)) {
        foreach ($envelope->last(TransportNamesStamp::class)->getTransportNames() as $senderAlias) {
            yield from $this->getSenderFromAlias($senderAlias);
        }
        return;  // <-- skips all routing below
    }

    // Otherwise, check ALL matching types (class, parents, interfaces)
    foreach (HandlersLocator::listTypes($envelope) as $type) {
        foreach ($this->sendersMap[$type] ?? [] as $senderAlias) {
            // yields ALL matching transports
        }
    }
}
```

Notice the key detail: if a TransportNamesStamp is present on the envelope, SendersLocator **skips the routing map entirely** and only uses the transports specified in the stamp.

**The correct approach: `shopware.messenger.routing_overwrite`**

Shopware provides a RoutingOverwriteMiddleware that runs **before** Symfony’s SendMessageMiddleware. It checks the shopware.messenger.routing\_overwrite configuration, and if a match is found, adds a TransportNamesStamp to the envelope. This stamp then causes SendersLocator to bypass the default routing entirely.

```php
// Shopware's RoutingOverwriteMiddleware (simplified)
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
    // Skip if already stamped
    if ($this->hasTransportStamp($envelope)) {
        return $stack->next()->handle($envelope, $stack);
    }

    // Check routing_overwrite config
    $transports = $this->getTransports($envelope, $this->routing, inherited: true);

    if (empty($transports)) {
        return $stack->next()->handle($envelope, $stack);  // fall through to Symfony routing
    }

    // Add TransportNamesStamp -- this overrides ALL Symfony routing
    return $stack->next()->handle(
        $envelope->with(new TransportNamesStamp($transports)),
        $stack
    );
}
```

Use routing\_overwrite like this:

```yaml
shopware:
  messenger:
    routing_overwrite:
      'Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexingMessage': indexer
      'Shopware\Core\Framework\DataAbstractionLayer\Indexing\MessageQueue\IterateEntityIndexerMessage': indexer
```

This ensures the message is sent **exclusively** to the indexer transport.

## Pitfall 2: Shared queue\_name in the Database

Even with correct routing, there’s a second problem. Shopware uses the Doctrine transport by default, which stores all messages in a single messenger\_messages database table. Transports are differentiated by the queue\_name column.

The default DSN (Data Source Name, the connection string that tells Symfony which transport backend to use) is:

```
doctrine://default?auto_setup=false
```

With no queue\_name parameter, the Doctrine transport defaults to queue\_name=default (defined in Symfony’s Connection class):

```php
// vendor/symfony/doctrine-messenger/Transport/Connection.php
protected const DEFAULT_OPTIONS = [
    'table_name' => 'messenger_messages',
    'queue_name' => 'default',       // <-- this is the default
    'redeliver_timeout' => 3600,
    'auto_setup' => true,
];
```

If you define your indexer transport using the same DSN without specifying a queue\_name:

```yaml
# This is WRONG -- both transports use queue_name=default
framework:
  messenger:
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # queue_name=default
      indexer:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # queue_name=default (!)
```

Both transports read from and write to the same queue\_name=default rows. When the async worker polls for messages:

```sql
SELECT * FROM messenger_messages
WHERE queue_name = 'default'
  AND (delivered_at IS NULL OR delivered_at < ...)
  AND available_at <= NOW()
```

It finds the indexer messages because they share the same queue\_name. **The routing was correct, but the database doesn’t distinguish between the transports.**

The fix is to give the indexer transport its own queue\_name:

```yaml
framework:
  messenger:
    transports:
      indexer:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          queue_name: indexer
```

> **Why options instead of DSN concatenation?** You may see examples that append &queue\_name=indexer directly to the DSN. This only works if the base DSN already contains a `?` query separator. Otherwise the resulting URL is invalid. Using options.queue\_name is portable, explicit, and works regardless of the DSN format. Shopware’s default low\_priority transport sets the value directly in the DSN because its base DSN is hardcoded with the `?` separator already present. For your own transports, stick with the options approach shown above.

## The Complete Configuration

Create the file config/packages/messenger.yaml:

```yaml
framework:
  messenger:
    transports:
      indexer:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          queue_name: indexer
        serializer: messenger.transport.symfony_serializer
        retry_strategy:
          max_retries: 3
          delay: 1000
          multiplier: 2
          max_delay: 0

shopware:
  messenger:
    routing_overwrite:
      'Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexingMessage': indexer
      'Shopware\Core\Framework\DataAbstractionLayer\Indexing\MessageQueue\IterateEntityIndexerMessage': indexer
```

This configuration does three things:

1. **Defines an indexer transport** with its own queue\_name=indexer in the database (set via options, not DSN concatenation)
2. **Routes EntityIndexingMessage** (the message for processing product batches) to the indexer transport exclusively
3. **Routes IterateEntityIndexerMessage** (the message that triggers batch iteration) to the indexer transport exclusively

After adding this file, clear the cache:

```bash
php bin/console cache:clear
```

## Docker / Deployment Setup

With the configuration in place, set up your workers so that only **one** worker consumes from the indexer transport:

```yaml
services:
  worker:
    # ... your base shopware service config
    entrypoint:
      - php
      - bin/console
      - messenger:consume
      - async
      - low_priority
      - --limit=300
      - --memory-limit=512M
    deploy:
      replicas: 3 # multiple replicas are fine for async + low_priority

  indexer-worker:
    # ... your base shopware service config
    entrypoint:
      - php
      - bin/console
      - messenger:consume
      - indexer
      - --limit=1000
      - --memory-limit=512M
    deploy:
      replicas: 1 # IMPORTANT: only ONE replica
```

Key points:

- The **async workers** consume from async and low\_priority. They never see indexer messages.
- The **indexer worker** consumes only from indexer. Only one instance runs at a time.
- In a multi-node setup, the indexer worker should run on **one node only**

To trigger queue-based indexing:

```bash
php bin/console dal:refresh:index --use-queue
```

## Verifying the Configuration

After applying the configuration, verify that messages are routed correctly:

1. **Stop all workers** (async and indexer)
   
2. **Trigger the indexer:**
   
   ```bash
   php bin/console dal:refresh:index --use-queue
   ```
   
3. **Check the database:**
   
   ```sql
   SELECT queue_name, COUNT(*) as count
   FROM messenger_messages
   GROUP BY queue_name;
   ```
   
   **Expected result:**
   
   | queue\_name | count |
   | --- | --- |
   | indexer | \> 0 |
   
   If you see messages with queue\_name=default, the routing is not working correctly. Double-check that you used shopware.messenger.routing\_overwrite (not framework.messenger.routing) and that your cache is cleared.
   
4. **Start only the async worker in verbose mode** and confirm it never picks up indexer messages:
   
   ```bash
   php bin/console messenger:consume async low_priority -vv
   ```
   
   Watch the output for a minute. You should see the worker polling but **never** receiving an EntityIndexingMessage or IterateEntityIndexerMessage. Meanwhile, verify the indexer queue remains untouched:
   
   ```sql
   SELECT COUNT(*) FROM messenger_messages WHERE queue_name = 'indexer';
   ```
   
   The count should remain the same as in step 3. If the async worker consumes indexer messages, the queue\_name is not set correctly on the transport. Revisit the options.queue\_name setting.
   
5. **Start the indexer worker.** The messages should now be consumed:
   
   ```bash
   php bin/console messenger:consume indexer -vv
   ```
   
   You should see it processing EntityIndexingMessage batches. The indexer queue count should drop to zero.
   

You can also use the built-in stats command:

```bash
php bin/console messenger:stats
```

This shows the message count per transport, including your new indexer transport.

## Production Operations: Monitoring the Indexer Worker

Running a single indexer worker means it can become a bottleneck or single point of failure. Add the following operational guardrails to your production setup:

**Monitor queue depth and age**

Poll the indexer queue regularly and alert when it grows beyond acceptable thresholds:

```sql
-- Current queue depth
SELECT COUNT(*) AS pending
FROM messenger_messages
WHERE queue_name = 'indexer'
  AND delivered_at IS NULL;

-- Oldest pending message (queue age)
SELECT TIMESTAMPDIFF(SECOND, MIN(available_at), NOW()) AS oldest_message_age_seconds
FROM messenger_messages
WHERE queue_name = 'indexer'
  AND delivered_at IS NULL;
```

**Suggested alert thresholds** (adjust to your catalog size):

| Metric | Warning | Critical |
| --- | --- | --- |
| Queue depth | \> 500 messages | \> 2000 messages |
| Oldest message age | \> 10 minutes | \> 30 minutes |

**Visual and built-in monitoring alternatives**

Instead of raw SQL queries, there are visual tools that make queue monitoring more accessible:

- The [FroshTools](https://github.com/FriendsOfShopware/FroshTools) plugin adds a **Queue Manager** to the Shopware administration that displays the message count per transport and provides a one-click queue reset. It is a handy alternative when you need a quick overview without SSH access.
  
- Starting with [Shopware 6.7.1.0](https://github.com/shopware/shopware/blob/trunk/changelog/release-6-7-1-0/2025-05-05-message-queues-statistics.md), the core ships **built-in message queue statistics**. A SentAtStamp records when each message enters the queue, and a MessageQueueStatsSubscriber stores processing metrics in a dedicated messenger\_stats table. Enable the feature via shopware.messenger.stats.enabled and configure the retention window with shopware.messenger.stats.time\_span. The data is accessible through a new administration settings page and the GET /api/\_info/message-stats.json API endpoint.
  

**Restart policy and healthcheck**

Ensure the indexer worker container restarts automatically on failure:

```yaml
indexer-worker:
  # ... service config
  restart: unless-stopped
  healthcheck:
    test: ['CMD', 'php', 'bin/console', 'messenger:stats']
    interval: 60s
    timeout: 10s
    retries: 3
```

**Handling backlog during large imports**

During bulk imports or large catalog updates, the indexer queue can grow significantly. Strategies to manage this:

1. **Temporarily increase —limit** on the indexer worker so it processes more messages per lifecycle
2. **Increase batch\_size** in shopware.dal.batch\_size to reduce the total number of messages generated (at the cost of longer transactions)
3. **Do not add more indexer worker replicas.** This reintroduces the deadlock problem. If throughput is insufficient, address the bottleneck at the batch/query level instead
4. **Monitor the failed transport** after large imports: php bin/console messenger:failed:show

## How Shopware Handles Deadlocks Internally

Even with a single indexer worker, occasional deadlocks can still occur if other processes (scheduled tasks, API requests, imports) write to the product table simultaneously. Shopware handles this gracefully at multiple levels:

**Level 1: RetryableTransaction (up to 10 retries)**

The individual updaters inside the ProductIndexer wrap their database operations in RetryableTransaction or RetryableQuery. These catch deadlock exceptions and retry the operation up to **10 times** with short delays:

```php
// Simplified from Shopware's RetryableTransaction
for ($counter = 0; $counter < self::MAX_RETRIES; ++$counter) {
    try {
        return $closure($connection);
    } catch (\Throwable $e) {
        if (!self::deadlockRelatedException($e)) {
            throw $e;
        }
        usleep(random_int(10, 20));
    }
}
```

Deadlock-related exceptions include DeadlockException, LockWaitTimeoutException, TransactionRolledBack, and the SAVEPOINT does not exist error.

**Level 2: Symfony Messenger retry (up to 3 retries)**

If the entire message handler fails (after exhausting the 10 internal retries), Symfony Messenger’s retry strategy kicks in. The default configuration retries up to **3 times** with exponential backoff:

| Retry | Delay |
| --- | --- |
| 1st | 1s |
| 2nd | 2s |
| 3rd | 4s |

A deadlock exception does not implement UnrecoverableExceptionInterface, so it is always eligible for retry. See the [Retries & Failures](https://symfony.com/doc/current/messenger.html#retries-failures) section of the Symfony Messenger documentation for details on configuring retry behavior.

**Level 3: Doctrine transport resilience**

The Doctrine transport’s DoctrineReceiver also handles deadlocks that occur while **fetching** messages (not during processing). If the SELECT … FOR UPDATE query deadlocks, the receiver silently returns an empty result up to 3 times before escalating. This prevents the worker from crashing due to transport-level contention.

**Level 4: Failed transport**

If all retries are exhausted, the message is moved to the failed transport rather than being discarded. You can inspect and retry failed messages:

```bash
# Show failed messages
php bin/console messenger:failed:show

# Retry all failed messages
php bin/console messenger:failed:retry

# Retry a specific message
php bin/console messenger:failed:retry 42
```

## Synchronous vs. Queue-Based Indexing

With the dedicated indexer transport in place, you have two options for running the indexer:

| Aspect | Synchronous (`dal:refresh:index`) | Queue-based (`--use-queue`) |
| --- | --- | --- |
| **Execution** | Single CLI process, blocks until done | Messages dispatched to queue, processed by worker |
| **Deadlock behavior** | Entire process aborts | Only the affected batch fails, rest continues |
| **Automatic retry** | None, must restart manually | Up to 3 retries with exponential backoff |
| **Message safety** | Progress is lost on failure | Messages persist in DB until acknowledged |
| **Parallelism** | N/A | Controlled via worker replicas |

**Recommendation:** Use —use-queue for production environments. It provides better resilience and doesn’t block your deployment pipeline.

## FAQ

**Why not just reduce the number of workers?**

Reducing async workers affects **all** message processing: emails, imports, webhooks, etc. The dedicated transport approach lets you keep high throughput for general messages while constraining only the indexer to a single worker.

**What about the batch\_size setting?**

Shopware’s shopware.dal.batch\_size (default: 50) controls how many entities are processed per indexer message. **Reducing** this value (e.g., to 25) decreases the lock footprint per transaction and can help with deadlocks. **Increasing** it (e.g., to 100) makes deadlocks more likely. However, with a single indexer worker, the default of 50 usually works fine.

**What if I see a message with delivered\_at stuck in the table?**

When a worker picks up a message, the Doctrine transport sets delivered\_at to the current timestamp. This acts as a lock, so other workers won’t pick up this message. If the worker is killed or crashes while processing, the message remains with delivered\_at set.

After the redeliver\_timeout (default: 3600 seconds / 1 hour), the transport considers the message stale and makes it available again:

```sql
WHERE delivered_at IS NULL OR delivered_at < NOW() - INTERVAL 3600 SECOND
```

This is normal behavior. The message will be automatically redelivered when the indexer worker restarts. You can reduce the timeout if needed:

```yaml
framework:
  messenger:
    transports:
      indexer:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          queue_name: indexer
          redeliver_timeout: 1800 # 30 minutes
```

**Can I use Redis instead of Doctrine for the indexer transport?**

Yes. If you’re already using Redis for other transports, you can use it for the indexer as well. The queue\_name pitfall is specific to the Doctrine transport. With Redis, each transport uses its own stream, so there’s no risk of cross-consumption. See the [Redis Transport](https://symfony.com/doc/current/messenger.html#redis-transport) section in the Symfony docs for DSN configuration.

**Should I also configure transaction\_isolation = READ-COMMITTED?**

This is a good general practice for Shopware installations. The default REPEATABLE-READ isolation level in MariaDB/MySQL can increase lock contention. However, it is not a replacement for the dedicated transport solution. It only reduces the probability of deadlocks, not the root cause.

---

_For more information on Symfony Messenger transports, routing, and worker configuration, see the [official Symfony Messenger documentation](https://symfony.com/doc/current/messenger.html)._
