Skip to content

fix(lakebase): deliver getLakebaseOrmConfig() pool settings to TypeORM and Sequelize - #731

Open
billmcmillin wants to merge 4 commits into
databricks:mainfrom
billmcmillin:fix/lakebase-orm-pool-config
Open

billmcmillin wants to merge 4 commits into
databricks:mainfrom
billmcmillin:fix/lakebase-orm-pool-config

Conversation

@billmcmillin

Copy link
Copy Markdown

Fixes #730.

Stacked on #719. This branch is based on #719, so the diff below also shows #719's two commits (0ab349f3, 888448ff) until it merges. Only the last commit, 2e44157b, belongs to this PR.

Problem

getLakebaseOrmConfig() is documented for TypeORM and Sequelize, but it returned pool and client settings at the top level, where neither ORM reads them. Unknown keys are silently ignored, so TypeORM and Sequelize users got:

  • No connection recycling. Connections stayed open indefinitely. Lakebase checks the OAuth token only at login, and access granted through Unity Catalog claims in the token stops working on a connection about 4 hours after the token was minted (42501 permission denied). fix(lakebase): recycle pool connections and pass through dropped pool options #719 fixes this for pg.Pool users; this PR fixes it for ORM users.
  • No timeouts. statement_timeout, query_timeout and the other client timeouts were ignored.
  • Sequelize only: no SSL setting. Sequelize reads ssl only from dialectOptions, so sslMode was ignored. Connections used TLS only if PGSSLMODE happened to be set in the environment.

Why each ORM ignored the settings, from their source:

  • TypeORM 0.3.28 (PostgresDriver.js:1185-1196) builds pg.Pool options from its own fields plus extra, and ignores top-level pg options.
  • Sequelize 6.37.7 passes only user, password, host, database and port to pg.Client from the top level; everything else comes from dialectOptions (postgres/connection-manager.js:72-96). Its pool, sequelize-pool, has no lifetime option.

Fix

getLakebaseOrmConfig() now also returns each setting where each ORM reads it. The existing top-level keys are kept, so current callers don't break.

Setting TypeORM Sequelize
Pool size poolSize pool.max
Connect timeout connectTimeoutMS dialectOptions.connectionTimeoutMillis
Application name applicationName dialectOptions.application_name
Lifetime extra.maxLifetimeSeconds pool.validate + hooks.afterConnect (age check)
Idle timeout extra.idleTimeoutMillis pool.idle
Statement, query and idle-in-transaction timeouts; keepAlive; options extra.* dialectOptions.*
SSL ssl (unchanged) dialectOptions.ssl
  • TypeORM's own option names. TypeORM applies extra last, over its own options. Pool size, connect timeout and application name therefore go under TypeORM's own names, so a caller's poolSize or connectTimeoutMS set after the spread still wins.
  • Sequelize recycling. afterConnect records when each connection opens. sequelize-pool calls validate before reusing an idle connection, and a connection older than the lifetime is destroyed and replaced. validate also repeats Sequelize's built-in health check (_invalid / _ending), which a custom validate replaces. As with pg-pool, a connection that's checked out isn't closed until it's released.
  • Merging. Each ORM ignores the other's keys. Callers who set their own extra, pool, dialectOptions or hooks must merge with the returned values; the README and JSDoc show how.
    • If hooks is replaced, lifetimes count from a connection's first reuse rather than when it opened, and a warning is logged once.
    • Rejecting such connections instead would make every reuse open a new connection.
  • Logging is opt-in. A logger is used only when the caller passes logger, so nothing new is printed by default. With a logger configured, fix(lakebase): recycle pool connections and pass through dropped pool options #719's lifetime warnings now reach ORM users too.
  • Docs. The README's TypeORM example now imports getLakebaseOrmConfig, which it used without importing.

Proof

Before and after against a real Postgres server

I ran embedded-postgres with the real TypeORM 0.3.28 and Sequelize 6.37.7, using @databricks/lakebase built from #719's tip (before) and from this branch (after).

Scenario TypeORM before → after Sequelize before → after
maxLifetimeSeconds: 2, idle 3 s, then pg_backend_pid() same pid (not recycled) → new pid same pid (not recycled) → new pid
statement_timeout: 500, then pg_sleep(1) succeeded (ignored) → "canceling statement due to statement timeout" succeeded (ignored) → canceled
sslMode: "require" against a server without TLS refused → refused connected in plaintext → refused
Caller sets poolSize: 20, connectTimeoutMS: 2000 max=20, 2000 → max=20, 2000 not applicable
sslMode: "disable", create/insert/select works → works works → works

Tests

packages/lakebase/src/__tests__/orm-config.test.ts has 15 tests. They run the real ORMs against a fake pg module, which records what the ORM passes to pg.Pool / pg.Client, so no database is needed.

  • TypeORM:
    • the lifetime, pool size and timeouts reach pg.Pool;
    • the caller's poolSize / connectTimeoutMS / applicationName win;
    • Lakebase values are used when the caller sets none;
    • a merged extra keeps both sides.
  • Sequelize:
    • each sslMode and the client timeouts reach pg.Client;
    • the pool size is used;
    • an idle connection is replaced once it's older than the lifetime, using a fake clock;
    • lifetime 0 never recycles;
    • connections Sequelize marks invalid are still dropped;
    • replaced hooks still recycle and warn once;
    • the documented merge pattern typechecks against Sequelize's types.
  • Logging: nothing reaches console without a logger; warnings and token errors reach a configured logger.

Against the original getLakebaseOrmConfig(), 12 of these 15 tests fail. The other three guard the new code: lifetime 0, invalid connections, and logging with a configured logger.

typeorm and sequelize are added as dev dependencies of @databricks/lakebase, pinned to the versions dev-playground already uses. They're imported only by this test file, so the published package is unchanged and the lockfile gains no new packages.

Checks run locally:

  • pnpm build, pnpm docs:build, pnpm -r typecheck, pnpm check and pnpm knip pass.
  • lakebase: 95 tests pass.
  • appkit: 4,243 tests pass. One fails, testing/tests/mock-workspace-client.test.ts ("formatting and structural equality neither throw nor recurse"), and it fails the same way without this change.

Behavior changes for Sequelize users

These are the intended effect of the fix, but they change what existing Sequelize apps get:

  • Pool defaults: the Lakebase defaults replace Sequelize's own: max 10 (was 5), idle 30 s (was 10 s), and a 10 s connect timeout (was none). Expect up to twice as many connections per app instance.
  • Recycling: connections are replaced after 30 minutes by default.
  • SSL follows sslMode. The default, require, means verified TLS. A local Postgres without TLS now needs sslMode: "disable" (or PGSSLMODE=disable); before, it worked without TLS because the setting was ignored.
  • PGSSLMODE=prefer no longer verifies certificates on Sequelize. It now maps to { rejectUnauthorized: false }, the same as for pg.Pool and TypeORM users. Before, pg's own PGSSLMODE handling verified them. Flagging this in case maintainers want prefer to verify on this path.

Unaffected: createLakebasePool, getLakebasePgConfig, Drizzle, Prisma and raw pg, which use the unchanged pg.Pool path.

Known limitations

  • Repeated warnings: with a warn-enabled logger, the lakebase plugin's getOrmConfig() rebuilds the config on every call, so the lifetime warning repeats per call. This only applies when a logger is configured.
  • Generated docs: the API docs page for getLakebaseOrmConfig shows internal variable names as default values (for example = pgConfig.max). That predates this PR (username = user); fixing it needs an exported return type.

This pull request and its description were written by Isaac.

billmcmillin and others added 4 commits November 6, 2026 14:00
… options

getLakebasePgConfig() built the pg.Pool config from a fixed list of 11
keys, so maxLifetimeSeconds, application_name, keepAlive, query_timeout
and options were silently dropped. Without a lifetime, pool connections
were never recycled. Access granted through Unity Catalog claims in a
Lakebase OAuth token stops working 4 hours after the token is minted
while the connection stays open, so a busy pool kept failing with
"permission denied" until the app restarted.

- Pass the five options through to pg.Pool, keeping the explicit key
  list.
- Default maxLifetimeSeconds to 1800 (30 minutes); an explicit 0 is
  kept.
- Warn when maxLifetimeSeconds is 0 or above 9000 (2.5 hours): the
  4-hour lapse, less up to an hour of token age at login, less a
  30-minute margin.
- Ignore a negative or non-finite maxLifetimeSeconds, with a warning,
  and use the default. pg-pool would otherwise close every connection
  as soon as it opens (negative) or never recycle (NaN).
- Document maxLifetimeSeconds, including that held clients are only
  recycled on release, and regenerate the API docs.
- Add unit tests for the default, the warnings and the pass-through.
- Add tools/lakebase-soak.ts, a soak harness that runs a real pool
  against a sandbox endpoint and checks connection age, token age at
  login, application_name and table access.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Bill McMillin <bill.mcmillin@databricks.com>
pg-pool passes maxLifetimeSeconds * 1000 to setTimeout. Above 2^31 - 1 ms
(about 24.8 days), Node fires the timer after 1 ms, so every connection
closed after a single use. Treat those values like Infinity: warn and use
the default. Compare in milliseconds so 2147483 s is kept and fractional
values just past the limit are rejected.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Bill McMillin <bill.mcmillin@databricks.com>
…M and Sequelize

getLakebaseOrmConfig() returned pool and client settings at the top level,
where neither ORM reads them, so TypeORM and Sequelize users silently lost
connection recycling, the pool size and timeouts, and (Sequelize) SSL.

- TypeORM: pool size, connect timeout and application name go under its
  own poolSize, connectTimeoutMS and applicationName, so caller values
  still win; lifetime, idle and statement timeouts go under extra.
- Sequelize: SSL and client timeouts go under dialectOptions, pool size
  and idle under pool. Its pool has no lifetime option, so connections
  are recycled by age with pool.validate and an afterConnect hook.
- Log only when the caller passes a logger, so nothing new is printed
  by default.

Fixes databricks#730

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Bill McMillin <bill.mcmillin@databricks.com>
@billmcmillin
billmcmillin requested a review from a team as a code owner November 12, 2026 16:24
@billmcmillin
billmcmillin requested a review from pkosiec November 12, 2026 16:24
@billmcmillin

Copy link
Copy Markdown
Author

Live check against a real Lakebase endpoint

I ran TypeORM 0.3.28 and Sequelize 6.37.7 against a Lakebase Autoscaling sandbox endpoint as a service principal. I used @databricks/lakebase built from #719's tip (before) and from this branch (after), with getLakebaseOrmConfig({ maxLifetimeSeconds: 120, application_name: "orm-live-demo", statement_timeout: 5000, max: 2 }), the default sslMode (require), and no PGSSLMODE in the environment.

Every 5 seconds for 6 minutes, each run asked the server about the connection its query ran on:

SELECT a.pid, a.backend_start, a.application_name, s.ssl,
       current_setting('statement_timeout') AS statement_timeout, now()
FROM pg_stat_activity a JOIN pg_stat_ssl s USING (pid)
WHERE a.pid = pg_backend_pid();

At the end, each run tried SELECT pg_sleep(6). The 5 s interval is under both ORMs' idle timeouts, so only the lifetime setting could replace a connection.

What the server reported TypeORM before TypeORM after Sequelize before Sequelize after
Connects with no PGSSLMODE set yes yes no: Invalid protocol version. Protocol version: 196608 yes
Distinct connections in 6 min 1 3 1 (with PGSSLMODE=require) 4
Oldest connection age 358 s (never recycled) 117 s 354 s (never recycled) 117 s
application_name "" orm-live-demo "" orm-live-demo
TLS (pg_stat_ssl.ssl) on on on (only through PGSSLMODE) on
statement_timeout 0 (not applied) 5s 0 (not applied) 5s
pg_sleep(6) succeeded canceled by the timeout succeeded canceled by the timeout
  • Sequelize couldn't connect to Lakebase before this fix, unless PGSSLMODE was set in the environment. Without it, the old config never passed ssl to Sequelize's pg.Client, so it opened a plaintext connection and Lakebase rejected it. With PGSSLMODE=require the old code connected, which confirms the cause. That run supplies the "before" Sequelize numbers for the other rows.
  • After the fix: both ORMs replaced connections at about the 120 s lifetime, and every setting reached the server.

This checks that the settings reach the server; it doesn't reproduce the 4-hour Unity Catalog access lapse. That lapse only affects tokens minted with claims, which getLakebaseOrmConfig() doesn't request by default. Here the shorter lifetime stands in for the default 30 minutes.

This branch has not been deployed

No deployments
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.

fix(lakebase): getLakebaseOrmConfig() pool settings don't reach TypeORM or Sequelize

1 participant