Problem/Motivation

The database driver for PostgreSQL is now mimicking how the database driver
for MySQL/MariaDB does things. To make the database driver for PostgreSQL work
like the one for MySQL/MariaDB it adds a lot of savepoints in transactions.
Savepoints in transactions are not free. Starting and ending a savepoint are
both a round trip to the database. Every Insert, Update, Upsert and Select
query uses savepoints. Instead of a single round trip to the database, we
have 3 round trips to the database. There is some performance improvement
possible.

Why the savepoints are there

PostgreSQL: any error poisons the whole transaction. When a
statement fails inside a transaction — even an "expected" failure like a
duplicate-key violation or a query against a table that doesn't exist yet —
PostgreSQL marks the entire transaction as aborted. Every subsequent statement
is rejected with

ERROR: current transaction is aborted, commands ignored
until end of transaction block

until you ROLLBACK. You
can't just catch the exception in PHP and carry on; the transaction is dead.

The only way to recover part of a transaction is a savepoint:
SAVEPOINT s1 before the risky statement, then
ROLLBACK TO SAVEPOINT s1 if it fails (which restores the
transaction to a usable state) or RELEASE SAVEPOINT s1 if it
succeeds.

MySQL/InnoDB: only the statement fails. When a statement
errors, InnoDB rolls back just that statement (or in a few edge cases like
deadlock, the transaction — but it tells you). The transaction itself remains
open and usable, so a catch block in PHP can simply try something else and
continue. No savepoint needed.

Why this matters for Drupal specifically: Drupal core has
several patterns that deliberately race or probe:

  • Merge / upsert-style logic: try an INSERT, catch the
    integrity-constraint violation, fall back to UPDATE.
  • Cache, lock, flood, key-value, queue backends: write to a table, and if
    it fails with "table not found", lazily create the table and retry
    (ensureTableExists() pattern).
  • ExceptionHandler for inserts catching duplicate-key
    exceptions.

On MySQL these catch-and-retry patterns just work, even when the caller has
an outer transaction open (e.g. during entity save). On PostgreSQL, without a
savepoint around the risky statement, the caller's transaction would be
aborted as collateral damage — an entity save would blow up because a
cache-set inside it hit a duplicate key. That's why the pgsql driver
historically wraps such statements in savepoints (addSavepoint()
/ releaseSavepoint() / rollbackSavepoint() on the
connection), and why this work moves those savepoints out of blanket driver
wrapping and into the specific consumers that actually do catch-and-continue —
the savepoint round-trips cost extra network chatter and each savepoint has
server-side overhead, so you only want them where a failure is genuinely
expected and recoverable.

Proposed resolution

  1. All savepoints in the Insert, Select, Update, and Upsert queries are
    removed.
  2. The backend database storage classes that use the trick with ensure
    table exists have been replaced with a verify that the table exists.

Performance testing

Base queries

Operation baseline improved Speedup
select 125.4 52.0 2.41x
insert 124.7 52.6 2.37x
update 125.0 53.4 2.34x
upsert (row exists) 127.8 56.8 2.25x
merge (row exists) 261.3 109.7 2.38x
merge (row missing) 257.2 182.9 1.41x

Backend storage classes

Suite Ops Speedup
keyvalue set / get / has / delete 2.1-2.5x
keyvalue setIfNotExists (merge, insert path) 1.4x
kv_expirable setWithExpire / get 2.3x
config write / read / exists 2.3-2.5x
queue createItem / claimItem / deleteItem 2.0-2.5x
flood register / isAllowed / clear 2.1-2.4x
lock acquire fresh / extend / maybeAvailable / release 1.9-2.5x
lock acquire contended 6.2x
batch getId / create / load / delete 2.2-2.6x
session write / read / destroy 2.0-2.5x
menu rebuild 200 links 1.8x (192ms → 104ms)
router dump 200 routes 1.07x

I have tested the baseline to the PR and the overall difference is about 2x
to 2.5x. performance improvement.

Remaining tasks

To make that possible we need a fundamental change in how the database
driver for PostgreSQL works. Does the database driver keep mimicking the
database driver for MySQL/MariaDB or are we going to change it and optimize
the database driver for working with a PostgreSQL database? That decision is
for the Drupal Core backend framework managers and maybe even the release
managers.

For the committer

The changes to the .gitlab-ci.yml file need to be removed before merging!

Issue fork drupal-3615690

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

daffie created an issue. See original summary.

daffie’s picture

Issue summary: View changes
daffie’s picture

Status: Active » Needs review

The CI pipeline is green for MySQL and PostgreSQL on PHP 8.5.
Ready for a review.

daffie’s picture

daffie’s picture

The savepoint in the method Schema::queryTableInformation() will be removed in #3615649: Cache the PostgreSQL table information.
The added methods ::verifyTableInTransaction() and verifyTable() should use the caching service from the same issue.

daffie’s picture

Disclosure: I have used AI on the PR, the IS and the CR.

daffie’s picture

Another example for why we Drupal on PostgreSQL should not be mimicking Drupal on MySQL/MariaDB is #3359406: Postgres: Sorting NULL values causes performance degradation.

daffie’s picture

Yet another example for why we Drupal on PostgreSQL should not be mimicking Drupal on MySQL/MariaDB is #3361618: Postgres forcing cases case-insensitivity causes serious performance degradation

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new822 bytes

The Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

daffie’s picture

Status: Needs work » Needs review

The CI pipeline is green again.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new98 bytes

The Needs Review Queue Bot tested this issue. The merge request has merge conflicts and cannot be merged. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

daffie’s picture

Status: Needs work » Needs review

Rebased the PR. Back to NR.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new91 bytes

The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

mradcliffe’s picture

I think since we are removing the savepoint handling from all queries we would want a change record to tell contrib. and custom module maintainers that use the core pattern that they will need to use savepoint handling or not use that core pattern anymore to maintain compatibility.

daffie’s picture

I think since we are removing the savepoint handling from all queries we would want a change record to tell contrib. and custom module maintainers that use the core pattern that they will need to use savepoint handling or not use that core pattern anymore to maintain compatibility.

You are right. But before we start doing that, lets first get the green light from the core maintainers.

daffie’s picture

Status: Needs work » Needs review

@mradcliffe: I fixed both of your remarks, only in other Proxyclasses do the same. See: https://git.drupalcode.org/project/drupal/-/blob/main/core/lib/Drupal/Co...

oily’s picture

Priority: Major » Critical
oily’s picture

Flipped this to critical as at least one other critical related issue has been postponed because this one will fix it.

smustgrave’s picture

Priority: Critical » Major

Doesn't hit critical

longwave’s picture

The service provider seems risky, because what if another module also wants to swap out one of those services? This doesn't feel very scalable unfortunately; the fact that we also have to swap workspaces.menu.tree_storage here points to that.

longwave’s picture

What if we added a recoverable or similar option to queries, which informs the Postgres driver (and others that might need to know) that it must wrap the query in a savepoint but leave the transaction usable?

Or, perhaps core explicitly needs to use transactions for these cases?

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

jmcerda’s picture

Added a draft correction targeting the existing issue branch, with a regression for TableVerifyTrait.

Create a cache bin inside a transaction, roll back its creation, then reuse the same backend inside an active transaction. The retained table-existence entry skips verification, and the next write fails with SQLSTATE 25P02. Both root transaction and nested savepoint rollback reproduce this.

The correction retains verification only outside transactions. Both new tests fail without it and pass with it: 2 tests, 14 assertions. Local PostgreSQL module tests pass with two skips. Remote PHPCS, PHPStan, PostgreSQL kernel and functional jobs pass.

Full CI remains red: the PostgreSQL JavaScript retry still fails one ThemeSettingsFormTest case after configuration submission. Its cause remains unestablished.

The draft adds table-existence queries during transactions, so its performance tradeoff needs review. The service-replacement question in #21–22 remains open.

jmcerda’s picture

Follow-up to #24: reproduced the ThemeSettingsFormTest failure locally. The hidden field and successful-save message appeared immediately after the failed lookup. The draft now waits for successful submission before checking file permanence. Ten local repetitions passed (20 tests, 170 assertions), and both cases pass in PostgreSQL CI.

All required CI jobs pass after separate CKEditor, media-library and layout-builder failures cleared on unchanged retries. Allowed PHP 8.6 unit warnings remain.

The correction has material cost. A local PostgreSQL 18/PHP 8.5 cache-backend benchmark, with 14 samples of 500 operations per variant, measured transactional reads at 0.261 → 0.709 ms/op and writes at 0.526 → 1.324 ms/op. Query counts doubled from one to two per operation; outside-transaction counts were unchanged. These are operation timings, not whole-site results.

Keeping the correction in draft. Safe table-information caching or transaction/savepoint-aware invalidation needs consideration; root and nested rollback coverage should remain.

jmcerda’s picture

Replaced the per-operation checks in the draft with verification cached against transaction and schema versions. Root transaction completion, savepoint rollback, and schema invalidation force a fresh check. Ordinary operations and savepoint releases reuse it.

Eight regressions cover query reuse, root/nested rollback, another backend creating the table, aborted COMMIT, drops, and renames: 51 assertions pass. The full PostgreSQL suite passes 111 tests with two skips.

In an alternating local benchmark, warmed transactional reads fell from 0.510 to 0.199 ms/op and writes from 0.917 to 0.349 ms/op. Each variant used 14 samples of 500 operations. Query counts fell from two to one per operation, matching the original proposal. These are operation timings, not whole-site results.

Required CI jobs pass after unchanged retries. The missing cache_file_parsing table failure matched the symptom in #3621147 and did not recur. Allowed PHP 8.6 unit warnings and a non-blocking log-copy warning remain. The MR remains draft for design review.

daffie’s picture

I have removed all the added service overrides. I have moved the code changes to the backend services themselves. They all work with the trait Drupal\Core\Database\TableVerifyTrait. This should fix the concerns from @longwave from the comment #21 and #22.
The testbot passes all tests with the acceptation of Drupal\Tests\text\Functional\Update\TextWithSummaryUnusedStorageUpdatePathTest. I have created #3621680: Remove Drupal\Tests\text\Functional\Update\TextWithSummaryUnusedStorageUpdatePathTest as the module no longer exists for the problem.

oily’s picture

I have re-read the most recent comments and related issues.

#24 to #28 look like progress.

edit: It looks safe to say #21 has been resolved.

Returning to #22:

What if we added a recoverable or similar option to queries, which informs the Postgres driver (and others that might need to know) that it must wrap the query in a savepoint but leave the transaction usable?

Or, perhaps core explicitly needs to use transactions for these cases?

  • Looks like we have not yet added a recoverable option. Has that turned out to be unnecessary? Or is it still a possibility?
  • Given the apparent progress made by jmcerda in his draft, is it time to merge in that code? If we are not ready for that, why? What more is required it?
  • Where longwave states, 'Or, perhaps core explicitly needs to use transactions for these cases?' Has that question been resolved by #24 to #28?
  • The IS predicts performance improvements. Have those been achieved? Or is there more work to do to? What more needs to be done?
oily’s picture

Status: Needs review » Needs work
oily’s picture

Re-reading the IS and #28, it looks like daffie's new MR!17042 is close to RTBTC. However, jmcerda's draft MR contains more feedback on performance statistics. However as asserted in #28 the pipeline is all green except for one test. daffie has created a novice follow-up to zap that gnat. It would still be useful to get clarity on the questions asked in #29.

jmcerda’s picture

@oily Thanks for following up. !17042 addresses the service-override concern in #21, but there are still a few things to resolve before I would consider it ready.

My draft fixes a specific rollback problem: a backend creates a table inside a transaction, that creation is rolled back, but the backend still remembers the table as existing. Reusing the same backend then skips verification, and a query against the missing table aborts the transaction.

Reading the current !17042 diff, the shared TableVerifyTrait still caches that result without rollback invalidation. Moving the trait therefore does not resolve this case. I have not yet rerun my regression tests against !17042. The draft targets the earlier branch, so the correction needs adapting to the shared trait and testing there. The invalidation approach also needs design review; whichever implementation we use should retain coverage for both root transaction and nested savepoint rollback.

On the recoverable option from #22: !17042 does not add one. It checks for missing tables before querying them and uses an explicit savepoint around table creation to handle a race with another process. That addresses one recovery case, but code that expects another kind of query failure and then continues an existing PostgreSQL transaction still needs a savepoint established before the query, followed by rollback to that savepoint on failure. Catching the PHP exception alone does not restore the transaction.

So the explicit-transaction approach is present in !17042, including a recovery test, but the broader API question remains open. A recoverable option is still a possibility. My draft does not settle that choice, and contrib/custom-code callers will need a change record explaining the resulting behavior.

The performance numbers in #25–26 compare two ways of fixing the rollback problem. The first correction checked table existence on every transactional operation, adding substantial overhead. The revised draft reuses verification until transaction or schema changes invalidate it. That restored the measured warmed cache operations to one query per operation.

Those results show that the revised correction avoids the repeated-check penalty. They do not independently confirm the issue summary's speedups against unmodified core, and they are not benchmarks of !17042 or whole-site performance. We should repeat the comparison on the combined implementation.

My next step would be to carry the rollback correction and tests into !17042. Alongside that, we still need agreement on the recovery behavior, a change record, and performance verification of the combined changes. The unrelated failing test is not the only remaining item.

daffie’s picture

Status: Needs work » Needs review

We are now adding a lot of transaction savepoints for PostgreSQL that makes the driver very slow. We are only adding those transaction savepoints to mimic how MySQL and MariaDB work. What I would like to do with this issue is to change how Drupal runs on PostgreSQL. Let's use PostgreSQL the way PostgreSQL works best. For Drupal core is the lazy table creation. It works great on MySQL/MariaDB and it is something that PostgreSQL does not like so much. PostgreSQL just likes a table to be there before you runs queries against it. Let's be honest. During a site in normal production environment no new tables will be created. The added transaction savepoints only make the site run slower.

Looks like we have not yet added a recoverable option. Has that turned out to be unnecessary? Or is it still a possibility? Explanation, please.

For PostgreSQL we need to change those backend to not do lazy table creation when using PostgreSQL, that all.

Given the apparent progress made by jmcerda in his draft, is it time to merge in that code? If we are not ready for that, why? What more is required it?

Done that.

Where longwave states, 'Or, perhaps core explicitly needs to use transactions for these cases?' Has that question been resolved by #24 to #28?

I am not sure. I think it would be better if we remove the whole lazy table creation from Drupal core.

Re: #28, daffie is this issue at the point where savepoint dependencies have been entirely removed? Or is there still residual dependency? If there is, is it something we can work on here to eradicate entirely? Or is it impossible yet to eradicate savepoint dependency?

We only have the transaction savepoints with PostgreSQL to mimic how MySQL/MariaDB works.

The IS predicts performance improvements. Have those been achieved? Or is there more work to do to? What more needs to be done?

Removing all those transactions savepoints for every select, update, insert, xxx queries will make the database driver a lot faster. Starting a transaction savepoint is a roundtrip to the database. Releasing a transaction savepoint is a roundtrip to the database. We are removing about 2/3 of all round trips to the database for PostgreSQL. That is the speed advantage. As a followup we should do #3615649: Cache the PostgreSQL table information and cache the table information in a pre-warmed cache. Much less calls for a hard table exist to the database. The current issue is already big, and yes it will not solve all problems. It is just a single step to make Drupal on PostgreSQL improve its performance.

Questions have been answered.

oily’s picture

Re: #32 and 33, thank you for the detailed responses. Moving to RTBTC. I won't be hurt if someone changes it back to Needs Review or other as it is quite a big, complex issue. But hopefully can get more feedback from a maintainer.

oily’s picture

Status: Needs review » Reviewed & tested by the community
jmcerda’s picture

Verified the incorporated changes at b89a9bae97. The rollback and PostgreSQL lock tests pass locally: 13 tests, 65 assertions. The broader PostgreSQL/transaction suite passes 133 cases; SQLite transaction/cache tests pass 43, with existing skips.

Required CI and the PostgreSQL child pipeline pass. Allowed PHP 8.6 unit warnings remain. I closed my overlapping draft !2 as superseded.

I repeated the cache benchmark against the incorporated implementation, alternating with core d107690b1c: 14 samples of 200 operations per scenario, PostgreSQL 18/PHP 8.5. Warmed transactional reads measured 0.532 → 0.195 ms/op; writes 1.502 → 0.276. Logged queries fell from three to one.

First use after commit or savepoint rollback adds verification. With a commit before every operation, writes measured 1.556 → 1.728 ms/op. These are operation timings, not whole-site results.

Native and fallback merge paths still catch integrity exceptions and retry without an explicit savepoint. I have not exercised those concurrency cases. The broader recovery contract and change record remain open for framework review.

jmcerda’s picture

Status: Reviewed & tested by the community » Needs work

Following up on #36, I reproduced both merge retry failures at b89a9bae97 with PostgreSQL 18 and PHP 8.5.

One connection inserts a key without committing. A second connection attempts that key through Drupal's merge API. The first commits only after PostgreSQL confirms the second is blocked on its transaction.

Inside an enclosing transaction, both native MERGE and the generic fallback fail with SQLSTATE 25P02 during retry. A subsequent SELECT 1 also fails before cleanup. Supplying an explicit serial key exercises the fallback.

I repeated five cases three times against both branches. Baseline d107690b1c passes all five. This branch fails the two transactional merge cases consistently. Autocommit merges and transactional upsert pass on both.

This fits the direction in #33: native conflict handling works for the simple keyed-write control. We still need to preserve Drupal's merge semantics, including the fallback, before treating these retries as safe.

Moving back to Needs work for these reproducible transaction failures.

jmcerda’s picture

The correction and reproducer are in draft !3, targeting the current issue branch at b89a9bae97.

Native MERGE and the generic fallback insert establish a savepoint inside an existing transaction, then restore it before retrying a conflict. Ordinary query savepoints remain removed. The shared merge class exposes its insert through a protected method for the PostgreSQL override.

The tests coordinate two database sessions through confirmed blocking. They also check that unrelated unique-constraint errors preserve their original SQLSTATE and preceding writes, including nested transactions.

Local PostgreSQL validation passes: 162 tests, 1,668 assertions, four existing skips. SQLite merge tests pass: 10 tests, 40 assertions. CSpell, PHPStan, and PHPCS pass. CI, including its PostgreSQL child pipeline, is running.

This retains savepoint round trips for transactional native MERGE. The draft preserves existing merge behavior; it does not replace the full merge API with upsert. That tradeoff remains for maintainer review.

jmcerda’s picture

Status: Needs work » Needs review

Main CI and the PostgreSQL pipeline now pass at a2d999025e. The allowed PHP 8.6 deprecation warnings remain.

I applied upstream revert cbef6228ca as a separate commit to clear the inherited text update failure discussed in #3621680. The text module also passes locally: 26 tests, 340 assertions. The merge correction remains isolated in c958f40cde.

The PostgreSQL JavaScript shard passed on retry after a workspace media-filter assertion failed. The earlier ManageDisplay element lookup failure also passed unchanged on retry. The new merge concurrency and constraint-preservation tests passed in both pipeline runs.

!3 is ready for review. Moving back to Needs review; the merge savepoint tradeoff and broader recovery contract still need maintainer review.

oily’s picture

Re: #38 and #39 I have reviewed the code and the pipeline/ tests. There is quite a lot of new code in the latest commit. Whether it could be reduced somehow I am not sure, but a maintainer has a lot of detail describing what has been done and why. I think given the pipeline is green RTBTC so maintainer can evaluate.

oily’s picture

Status: Needs review » Reviewed & tested by the community
longwave’s picture

@jmcerda Your comments read a lot like AI output. Please read the AI contribution policy, specifically around using your own words and not just copy and pasting from an LLM.

https://www.drupal.org/docs/develop/issues/issue-procedures-and-etiquett...

jmcerda’s picture

@longwave. I am familiar with the policy. Thank you.