Issue Summary updated from comment #11 by @sdjili.

Problem/Motivation

The DatabaseCacheTagsChecksum service registers rootTransactionEndCallback as a post-transaction callback. This callback runs doInvalidateTags() which updates the cachetags table via the database Connection.

When PHP shutdown begins, object destruction order is undefined. If Connection::__destruct runs before Transaction::__destruct, the Connection sets $this->connection = NULL (PDO) before the post-transaction callbacks execute. When rootTransactionEndCallbackpurge()processPostTransactionCallbacks() runs, it uses a Connection whose PDO is already null, causing the error and preventing the cachetags table from being updated.

The issue can also happen with any module declaring a post transaction callback via \Drupal\Core\Database\Transaction\TransactionManagerInterface::addPostTransactionCallback.

Steps to reproduce

Reproduction seem complicated as it's related to a race condition in the garbage collector.

Proposed resolution

The commitAll() method in TransactionManagerBase already unpiles the transaction stack during shutdown. We should also run processPostTransactionCallbacks() there, before any destructors run. This ensures cache tag invalidation (and any other post-transaction callbacks) execute while the Connection is still valid.

Original report by @titouille

The error occurs when I create an article and suppress it when I'm on admin administration content page.

Steps to reproduce

  • Install a fresh version of drupal 11.3.2 with standard profile.
  • Go to /admin/content
  • Create a new article content and save it.
  • Return on /admin/content
  • Delete the content.

The stack trace displayed at screen :

( ! ) Fatal error: Uncaught TypeError: Drupal\Core\Database\StatementWrapperIterator::__construct(): Argument #2 ($clientConnection) must be of type object, null given, called in /web/core/lib/Drupal/Core/Database/Connection.php on line 441 and defined in /web/core/lib/Drupal/Core/Database/StatementWrapperIterator.php on line 38

[...]

I was using drupal 11.2 and all worked fine, before I updated to drupal 11.3...

using httpd (homebrew) with php8.3 on macos.

CommentFileSizeAuthor
#19 3569316-13.patch1.09 KBduaelfr

Issue fork drupal-3569316

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

titouille created an issue. See original summary.

titouille’s picture

Priority: Normal » Major

I think this problem should be classified as major, even critical, because it poses a real issue for end users. In my case, for example, users can add, modify, and delete content. If they encounter an error like this (stack error message), the situation becomes untenable in production.

The problem occurs when the system try to execute the cache invalidation. The database connection is closed before and the invalidation cannot be done.

I'm actually surprised that no one else has reported this problem before me.

mondrake’s picture

mondrake’s picture

mondrake’s picture

titouille’s picture

Hi Mondrake and thank you for your quick reply. I can confirm that with the fork drupal-3495554 (Error unpiling database transactions with MySQL driver in kernel tests) the error message disapears.

drupal-3447097 (TransactionNameNonUniqueException: A transaction named drupal_transaction is already in use)
and drupal-3406985 (Convert all transactions in core to use explicit ::commitOrRelease(), deprecate implicit commit-on-destruct)

continue to display the same error.

titouille’s picture

Actually I do not have any solution instead of overriding DatabaseCacheTagsChecksums and add a try/catch to manage the cache invalidation in another way when the connection is already lost.


namespace Drupal\mymodule;

use Drupal\Core\Cache\DatabaseCacheTagsChecksum;

/**
 * Defines the deferred cache tags checksum.
 */
class DeferredCacheTagsChecksum extends DatabaseCacheTagsChecksum {

  /**
   * {@inheritDoc}
   */
  public function doInvalidateTags(array $tags) {

    // Add a try/catch to intercept error if client connection is lost.
    try {
      if ($this->connection->getClientConnection() === NULL) {
        $this->deferInvalidation($tags);
      }
      else {
        parent::doInvalidateTags($tags);
      }
    }
    catch (\TypeError $e) {
      // Defer invalidation if connection is lost.
      if (str_contains($e->getMessage(), 'getClientConnection(): Return value must be of type object, null returned')) {
        $this->deferInvalidation($tags);
      }
      else {
        throw $e;
      }
    }
  }

  /**
   * Defer the invalidation in case the connection is closed.
   *
   * @param array $tags
   *   The tags to invalidate.
   */
  public function deferInvalidation(array $tags) {
    DeferredCacheTagsStore::add($tags);
  }

}

The deferred cache tags store use a file to store tags, because nor State nor $_SESSION nor database can be used at the time of the process :


namespace Drupal\mymodule;

/**
 * Store deferred cache tags safely across shutdown boundaries.
 */
class DeferredCacheTagsStore {

  /**
   * File path for storing deferred tags.
   */
  protected static string $file = DRUPAL_ROOT . '/sites/default/files/deferred_cache_tags.json';

  /**
   * Add cache tags to the store.
   *
   * @param string[] $tags
   *   Tags to add.
   */
  public static function add(array $tags): void {
    $existing = self::all();

    // Merge unique tags.
    $merged = array_unique(array_merge($existing, $tags));

    // Save to file.
    file_put_contents(self::$file, json_encode($merged));
  }

  /**
   * Get all stored tags.
   *
   * @return string[]
   *   Array of tags.
   */
  public static function all(): array {
    if (!file_exists(self::$file)) {
      return [];
    }

    $contents = file_get_contents(self::$file);
    $tags = json_decode($contents, TRUE);

    if (!is_array($tags)) {
      return [];
    }

    return $tags;
  }

  /**
   * Clear stored tags.
   */
  public static function clear(): void {
    if (file_exists(self::$file)) {
      unlink(self::$file);
    }
  }

}

And an event listener for kernel.response is used to really invalidate the cache and redirect the user to the same page, to avoid any remanent data / error messages.


namespace Drupal\mymodule\EventSubscriber;

use Drupal\Core\Cache\CacheTagsInvalidator;
use Drupal\Core\State\StateInterface;
use Drupal\site_generator\DeferredCacheTagsStore;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Defines the "do invalidate tags" subscriber on kernel terminate event.
 */
class DoInvalidateTagsSubscriber implements EventSubscriberInterface {

  /**
   * Constructor.
   *
   * @param \Drupal\Core\State\StateInterface $state
   *   The state service.
   * @param \Drupal\Core\Cache\CacheTagsInvalidator $cacheTagsInvalidator
   *   The cache tags invalidator service.
   */
  public function __construct(
    protected StateInterface $state,
    protected CacheTagsInvalidator $cacheTagsInvalidator,
  ) {}

  /**
   * {@inheritDoc}
   */
  public static function getSubscribedEvents() {
    return [
      KernelEvents::RESPONSE => 'onKernelResponse',
    ];
  }

  /**
   * Execute task on kernel terminate.
   *
   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
   *   The terminate event.
   */
  public function onKernelResponse(ResponseEvent $event) {

    $tags = DeferredCacheTagsStore::all();

    if (!$tags) {
      return;
    }

    // Invalidate cache for deferred tags.
    $this->cacheTagsInvalidator->invalidateTags($tags);
    DeferredCacheTagsStore::clear();

    $request = $event->getRequest();

    // Redirect to avoid error messages and ghost lines in admin.
    // Check for 'cache_refreshed' tag to not loop.
    if ($request->isMethod('GET') && !$request->query->get('cache_refreshed')) {
      $current_url = $request->getUri();

      // Add no loop tag in url.
      $redirect_url = $current_url;
      $separator = strpos($current_url, '?') === FALSE ? '?' : '&';
      $redirect_url .= $separator . 'cache_refreshed=1';

      $response = new RedirectResponse($redirect_url);
      $event->setResponse($response);
    }
  }

}

And finally, in the services.yml file :

cache_tags.invalidator.checksum:
    class: \Drupal\mymodule\DeferredCacheTagsChecksum
    arguments: ['@database']
    tags:
      - { name: cache_tags_invalidator}
      - { name: backend_overridable }
  Drupal\Core\Cache\CacheTagsChecksumInterface: '@cache_tags.invalidator.checksum'

  mymodule.do_invalidate_tags_subscriber:
    class: Drupal\mymodule\EventSubscriber\DoInvalidateTagsSubscriber
    arguments:
      - '@state'
      - '@cache_tags.invalidator'
    tags:
      - { name: event_subscriber }

This is the solution I found after tried a lot of other solutions.
This is just a fallback. If the problem is resolved in the core, this code will work like the core DatabaseCacheTagsChecksum works actually.
Now if anyone has better I'm listening on it.

sdjili’s picture

This is a production-critical issue. We see the same
StatementWrapperIterator::__construct(): Argument #2 ($clientConnection) must be of type object, null given error
in our live environment.
In our case, the error does not follow a single, reproducible path. Random occurrence It can occur during:
- Node edit and save, AJAX form submissions
- Media library operations (editing media, opening the media library modal)
- Various content management operations
We have not been able to identify a scenario where it happens every time.
It seems to be timing-related and tied to object destruction order during PHP-FPM request shutdown.
Same underlying error
Our stack trace matches the one in this ticket: the error happens in Connection::prepareStatement when $clientConnection is null, triggered during cache tag invalidation in post-transaction callbacks (DatabaseCacheTagsChecksum::doInvalidateTags → rootTransactionEndCallback).
Environment
Drupal 11.3.2
drupal/mysql57 driver
multi-site, replicas

Any fix that addresses the destructor-order problem would help all affected use cases, not only the create/delete article flow.
Thank you for looking into this.

adraco’s picture

Hi all, I can confirm I’m seeing this as well.

On Drupal 11.3.3 with PostgreSQL, I intermittently hit:

TypeError: Drupal\Core\Database\StatementWrapperIterator::__construct(): Argument #2 ($clientConnection) must be of type object, null given

It seems to happen when Drupal invalidates cache tags at the end of the request (CacheTagsChecksum / rootTransactionEndCallback). When it triggers, cache invalidation doesn’t complete properly and I get stale results (e.g. deleted nodes still appearing in a View/frontpage until drush cr, and occasionally new content not showing up until caches are rebuilt).

PHP versions tested:
8.3.30 — no issue
8.4.17-18 — issue occurs
8.5.3 — I no longer see the CacheTagsChecksum TypeError (I do see some unrelated PHP 8.5 deprecation notices from contrib modules)

Disabling Xdebug didn’t resolve it for me on 8.4.x.

sdjili’s picture

Hi, I am adding some informations :
The issue arises only in the production environment, which includes multiple front ends, two database replicas, and a shared Memcached with two web fronts.
PHP version: 8.3.27
Observable symptom:
When the bug occurs, the cachetags table is not updated after a successful node save:
Expected: rows for node:xxx, node_list, 4xx-response, etc., or their invalidations incremented.
Actual: no new rows and no increments; cachetags stays unchanged.
Step-by-step flow
1. Node save → transaction starts → node data written.
2. Cache::invalidateTags(['node:xxx', 'node_list', ...]) → called from EntityBase::invalidateTagsOnSave().
3. Deferred invalidation → inTransaction() is true → tags go into $delayedTags → rootTransactionEndCallback registered.
4. Transaction commits → form completes.
5. Shutdown → PHP destroys objects in an undefined order.
6. Bug: Connection::__destruct runs first → sets $this->connection = NULL.
7. Transaction destructor runs → purge() → processPostTransactionCallbacks() → rootTransactionEndCallback → doInvalidateTags().
8. Failure: merge('cachetags') → prepareStatement() → PDO is null → TypeError → no rows written to cachetags.
9. Result: Node save succeeds, but cachetags is never updated, so cache checksums stay stale and Memcache keeps serving old content.
Stack trace

TypeError: Drupal\Core\Database\StatementWrapperIterator::__construct(): Argument #2 ($clientConnection) must be of type object, null given, called in docroot/core/lib/Drupal/Core/Database/Connection.php on line 441 in docroot/core/lib/Drupal/Core/Database/StatementWrapperIterator.php on line 38 #0 docroot/core/lib/Drupal/Core/Database/Connection.php(441): Drupal\Core\Database\StatementWrapperIterator->__construct(Object(Drupal\mysql57\Driver\Database\mysql\Connection), NULL, 'SELECT 1 AS "ex...', Array, false)
#1 docroot/core/lib/Drupal/Core/Database/Connection.php(664): Drupal\Core\Database\Connection->prepareStatement('SELECT 1 AS "ex...', Array)
#2 docroot/core/lib/Drupal/Core/Database/Query/Select.php(521): Drupal\Core\Database\Connection->query('SELECT 1 AS "ex...', Array, Array)
#3 docroot/core/lib/Drupal/Core/Database/Query/Merge.php(366): Drupal\Core\Database\Query\Select->execute()
#4 docroot/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php(42): Drupal\Core\Database\Query\Merge->execute()
#5 docroot/core/lib/Drupal/Core/Cache/CacheTagsChecksumTrait.php(50): Drupal\Core\Cache\DatabaseCacheTagsChecksum->doInvalidateTags(Array)
#6 [internal function]: Drupal\Core\Cache\DatabaseCacheTagsChecksum->rootTransactionEndCallback(true)
#7 docroot/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php(537): call_user_func(Array, true)
#8 docroot/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php(309): Drupal\Core\Database\Transaction\TransactionManagerBase->processPostTransactionCallbacks()
#9 docroot/core/lib/Drupal/Core/Database/Transaction.php(38): Drupal\Core\Database\Transaction\TransactionManagerBase->purge('drupal_transact...', '698d72c3ba91c0....')
#10 [internal function]: Drupal\Core\Database\Transaction->__destruct()
#11 {main} request_id="v-de836ea4-07db-11f1-9edc-533a208bd787"

Thanks.

sdjili’s picture

Root Cause

The `DatabaseCacheTagsChecksum` service registers `rootTransactionEndCallback` as a post-transaction callback. This callback runs `doInvalidateTags()` which updates the `cachetags` table via the database Connection.

When PHP shutdown begins, object destruction order is undefined. If `Connection::__destruct` runs before `Transaction::__destruct`, the Connection sets `$this->connection = NULL` (PDO) before the post-transaction callbacks execute. When `rootTransactionEndCallback` → `purge()` → `processPostTransactionCallbacks()` runs, it uses a Connection whose PDO is already null, causing the error and preventing the `cachetags` table from being updated.

Proposed Fix (extends existing approach)

The `commitAll()` method in `TransactionManagerBase` already unpiles the transaction stack during shutdown. We should also run `processPostTransactionCallbacks()` there, **before** any destructors run. This ensures cache tag invalidation (and any other post-transaction callbacks) execute while the Connection is still valid.

**Patch for `core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php`:**

diff
   public function commitAll(): void {
     foreach (array_reverse($this->stack()) as $id => $item) {
       $this->unpile($item->name, $id);
     }
+    // Run post-transaction callbacks now (while Connection is still valid).
+    // Prevents destructor-order bug: Connection::__destruct can run before
+    // Transaction::__destruct, leaving PDO null when callbacks run.
+    $this->processPostTransactionCallbacks();
   }

Testing

We applied this patch in production and confirmed:
- The `StatementWrapperIterator' error no longer occurs
- Cache tag entries and checksums are correctly inserted into the `cachetags` table after node saves
- Cache invalidation works as expected

Additional Note

Because `commitAllOnShutdown` runs during PHP shutdown (after the HTTP response is sent), there can be a race where the redirect response from a node save is sent before cache invalidation completes. In multi-worker setups (e.g. PHP-FPM), the follow-up request may hit a different worker before the first request’s shutdown has run. A separate improvement would be to run `commitAll()` earlier in the request lifecycle (e.g. on `KernelEvents::RESPONSE`) so cache invalidation completes before the response is sent.

jsutta made their first commit to this issue’s fork.

jsutta’s picture

I ran into this issue on Drupal 11.3.5 with Drush 13.7.2.0 and PHP 8.3.30. I hope it's ok but I went ahead and added the code from #11 to a merge request after it worked for me so I could install it via Composer.

bobooon’s picture

This worked for me as well on Drupal 11.3.5 with PHP 8.3. In my case, the issue occurred after a user changed their preferred language, either via a language switcher block or on their user account form. After applying the patch, the error no longer occurred, and the cache bins were updated accordingly.

spfaffly’s picture

Can confirm @jsutta 's fix helped this issue for me. Thanks for the MR!

astutonet’s picture

I was having problems running update.php on a local installation with D11.3.9, PHP 8.3.6, and the code from the proposed solution in #11 solved the issue.

agoradesign’s picture

The MR works for me too:

on my D11.3.11 site, I've added an "event" content type, having a mandatory date range field. I wanted to include the start date in the pathauto pattern. I've tried a few different patterns, but in any case I've experienced always the same error: the node incl url alias is saved correctly, but the immediate redirect to the node detail page is failing with a HTTP 500 error (because of the "client connection... must be of type..." error described in the summary). Until the cache is cleared, any further call to the node page fails with an 404 error

After patching the MR from here, the problem is gone :)

here's my pathauto pattern used: '/termine/[node:field_date:start_date:html_date]/[node:title]'

duaelfr’s picture

Status: Active » Needs review
Issue tags: +Needs subsystem maintainer review
StatusFileSize
new1.09 KB

I ran into that issue by using the Node Revision Delete module that uses a post transaction callback to create queue items.
It was quite hard to understand what was the issue exactly and if my custom code was involved (it was not).

I can confirm the patch from the MR is working. The code looks good and the tests failure seem unrelated.

I think we would need some maintainer opinion on this one because they might have some other way to fix the issue.


Patch attached for composer.
duaelfr’s picture

Issue summary: View changes

Updated IS.

smustgrave’s picture

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

may help to get a test case showing the real world example

swild-ma’s picture

On our D11.3.3 site, the error occurs each time when i empty the caches (/admin/flush?token=xxx). After applying the patch (https://www.drupal.org/files/issues/2026-06-16/3569316-13.patch) the error does not happen anymore. Thanks!

duaelfr’s picture

Status: Needs work » Needs review
Issue tags: -Needs tests

Tests coverage have been expanded and tests-only job fails as expected.

daffie’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: -Needs subsystem maintainer review

Looks good to me.
Testing has been added.
Testing fails without the fix.
For me it is RTBC.
Removing the tag "Needs subsystem maintainer review"

godotislate made their first commit to this issue’s fork.

  • godotislate committed 3f036e35 on main
    fix: #3569316 Client connection () must be of type object, null given...
godotislate’s picture

Version: main » 11.4.x-dev
Status: Reviewed & tested by the community » Patch (to be ported)

I rebased the MR because there was an upstream test failure that has since been resolved.

Test only fails as expected: https://git.drupalcode.org/project/drupal/-/jobs/11124128

Committed 3f036e3 and pushed to main. Thanks!

Holding off on 11.x and 11.4.x because there are upstream test failures that hopefully can get resolved first soon. Will check back in a bit.

  • godotislate committed 3fd86f3d on 11.4.x
    fix: #3569316 Client connection () must be of type object, null given...

  • godotislate committed 5111b874 on 11.x
    fix: #3569316 Client connection () must be of type object, null given...
godotislate’s picture

Status: Patch (to be ported) » Fixed

Committed and pushed 5111b87 to 11.x and 3fd86f3 to 11.4.x. Thanks!

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

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

Maintainers, credit people who helped resolve this issue.

  • godotislate committed 3f81afa1 on 11.x
    Revert "fix: #3569316 Client connection () must be of type object, null...

  • godotislate committed a1b67d8d on 11.4.x
    Revert "fix: #3569316 Client connection () must be of type object, null...
godotislate’s picture

Status: Fixed » Patch (to be ported)

Revert for 11.x and 11.4.x because of PHPStan failures.

11.x: https://git.drupalcode.org/project/drupal/-/jobs/11132475
11.4.x: https://git.drupalcode.org/project/drupal/-/jobs/11132455

------ ---------------------------------------------------------------------- 
  Line   core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php       
 ------ ---------------------------------------------------------------------- 
  1268   No error with identifier unset.possiblyHookedProperty is reported on  
         line 1268.                                                            
         🪪  ignore.unmatchedIdentifier (non-ignorable)                        
 ------ ---------------------------------------------------------------------- 

We should open an 11.x MR to make sure. I can do it later if no one else gets to it.

  • godotislate committed 7634c658 on 11.x
    fix: #3569316 Client connection () must be of type object, null given...

  • godotislate committed 478fb374 on 11.4.x
    fix: #3569316 Client connection () must be of type object, null given...
godotislate’s picture

Status: Patch (to be ported) » Fixed

MR 16423 with one line change to remove // @phpstan-ignore unset.possiblyHookedProperty from TransactionTest.php passes.

Committed and pushed 7634c65 to 11.x and 478fb37 to 11.4.x. Thanks!

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

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

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

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