Skip to content

fix: await release in commit/rollback, set in-memory pool to max:1#1531

Draft
hm23 wants to merge 1 commit intomainfrom
fix/builtin-pool-compat
Draft

fix: await release in commit/rollback, set in-memory pool to max:1#1531
hm23 wants to merge 1 commit intomainfrom
fix/builtin-pool-compat

Conversation

@hm23
Copy link
Contributor

@hm23 hm23 commented Mar 6, 2026

Summary

This PR fixes two latent bugs in DatabaseService that are masked when using generic-pool but surface when using cds.env.features.pool = 'builtin'.

The goal of the builtin pool is to be a drop-in replacement for generic-pool. These fixes are needed to achieve that — and they are also correct standalone improvements, since both were pre-existing issues that happened not to matter with the old pool's synchronous internals.


Fix 1: await this.release() in commit() and rollback()

Root cause

DatabaseService.commit() and rollback() called this.release() without await:

// before
this.release() // missing await

With generic-pool, pool.release() performs all its internal bookkeeping synchronously (it only returns a Promise for API compatibility). So even without await, the connection was returned to the pool before any other microtask could observe the pool state — the bug was silently harmless.

The builtin pool's release() is genuinely async: it enqueues the returning connection and resolves pending acquire calls asynchronously. Without await, the caller continues before the connection is returned, leading to connection leaks and — when all pool slots are taken — acquire timeouts or stale-connection errors.

Fix

await this.release()

Backward compatibility

generic-pool's release() also returns a Promise. Awaiting it was always valid — it just wasn't necessary before. This change is fully backward-compatible.


Fix 2: cap :memory: SQLite pool to max: 1

Root cause

Each call to new sqlite(':memory:') (both better-sqlite3 and Node.js builtin sqlite) creates a completely independent, isolated in-memory database. There is no shared :memory: that multiple connections attach to.

With a pool max > 1, concurrent requests can acquire different connections, each pointing to a separate empty database with no schema, causing no such table errors.

Note: SQLite does offer file::memory:?cache=shared for shared in-memory databases, but neither driver uses it.

Fix

const isMemory = this.url4() === ':memory:'
options: isMemory ? { max: 1, ...this.options.pool } : this.options.pool || {}

Setting max: 1 for :memory: databases serializes all access through the single shared connection, which has the schema applied to it.

User configuration (via options.pool) still takes precedence through the spread, so users who know what they're doing can override.

Backward compatibility

With generic-pool's synchronous release(), increasing max above 1 for :memory: made no observable behavioral difference — the connection was returned synchronously before any queued acquire could run, so requests were already effectively serialized. This fix preserves that behavior explicitly.


Why making builtin pool.release() synchronous is not the right fix

An alternative approach — making the builtin pool's release() synchronous to match generic-pool's internals — was investigated and ruled out:

  • DatabaseService.release() is itself async, so even a synchronous pool.release() call would still yield to the event loop before the connection is returned (due to the async function's implicit Promise wrapping).
  • Making DatabaseService.release() synchronous too would break the dbc.destroy pattern used in some subclasses.
  • Double-release scenarios would throw synchronously instead of rejecting a Promise, crashing callers that don't expect synchronous throws.
  • Each individual fix triggered a cascade of new failures; the full change surface would be large and risky.

The correct fix is to properly await async operations — which is what this PR does.


Behavioral changes for users switching to the builtin pool

  • Missing await on release() in user subclasses of DatabaseService would now surface as bugs. This is a pre-existing latent issue, now exposed.
  • No acquire timeout by default: the builtin pool defaults to acquireTimeoutMillis: null (no timeout), whereas generic-pool has a default timeout. Misconfigured services that never release connections would silently queue forever instead of timing out. This can be configured via cds.env.requires.<db>.pool.acquireTimeoutMillis.

Testing

The full tests/_runtime suite was run with cds.env.features.pool = 'builtin' after these fixes. All pre-existing test failures (17, all in -x4 and REST test variants) remained unchanged. Zero new failures were introduced.

Two latent bugs surfaced when enabling cds.env.features.pool='builtin':

1. DatabaseService.commit() and .rollback() called this.release() without
   await. With generic-pool the synchronous internals masked this: the
   connection was returned before any concurrent acquire could observe the
   pool state. The builtin pool's release() is async (Promise-returning),
   so without await the connection is not returned before the caller
   continues, leading to connection leaks and stale-connection errors.

2. SQLiteService with ':memory:' databases: each sqlite(':memory:') call
   creates a completely independent, isolated in-memory database. With a
   pool max > 1, concurrent requests could acquire different connections,
   each pointing to a separate empty database with no schema, causing
   'no such table' errors. Capping the pool at max:1 for :memory:
   databases serializes access through the single shared connection.
   This is backward-compatible: generic-pool's synchronous release()
   already serialized access effectively, and user config can still
   override via options.pool.

Both fixes are backward-compatible with generic-pool.
@hm23 hm23 changed the title fix: await release() in commit/rollback; cap :memory: pool to max:1 fix: await release in commit/rollback, set in-memory pool to max:1 Mar 6, 2026
@johannes-vogel
Copy link
Contributor

The max: 1 setting should not be needed since the setting should be applied through the package.json config.

@hm23 hm23 mentioned this pull request Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants