feat(sap): use the native driver for connection pooling (#11520)

* feat(sap): use the native driver for connection pooling

* Add pool error handler
This commit is contained in:
Lucian Mocanu 2025-07-01 23:43:12 +02:00 committed by GitHub
parent 5904ac3db2
commit aebc7ebc67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 278 additions and 178 deletions

View File

@ -203,9 +203,7 @@ await timber.remove()
- **SAP Hana**
```
npm config set @sap:registry https://npm.sap.com
npm i @sap/hana-client
npm i hdb-pool
```
##### TypeScript 配置

View File

@ -215,11 +215,8 @@ await timber.remove()
```
npm install @sap/hana-client
npm install hdb-pool
```
_SAP Hana support made possible by the sponsorship of [Neptune Software](https://www.neptune-software.com/)._
- for **Google Cloud Spanner**
```

View File

@ -176,11 +176,8 @@ await timber.remove()
```
npm i @sap/hana-client
npm i hdb-pool
```
_[Neptune Software](https://www.neptune-software.com/)의 후원으로 SAP Hana 지원이 가능해졌다._
- **MongoDB** (experimental)의 경우
`npm install mongodb@^5.2.0 --save`

View File

@ -544,6 +544,24 @@ The following TNS connection string will be used in the next explanations:
- `sid` - The System Identifier (SID) identifies a specific database instance. For example, "sales".
- `serviceName` - The Service Name is an identifier of a database service. For example, `sales.us.example.com`.
## `sap` data source options
- `host` - The hostname of the SAP HANA server. For example, `"localhost"`.
- `port` - The port number of the SAP HANA server. For example, `30015`.
- `username` - The username to connect to the SAP HANA server. For example, `"SYSTEM"`.
- `password` - The password to connect to the SAP HANA server. For example, `"password"`.
- `database` - The name of the database to connect to. For example, `"HXE"`.
- `encrypt` - Whether to encrypt the connection. For example, `true`.
- `sslValidateCertificate` - Whether to validate the SSL certificate. For example, `true`.
- `key`, `cert` and `ca` - Private key, public certificate and certificate authority for the encrypted connection.
- `pool` — Connection pool configuration object:
- `maxConnectedOrPooled` (number) — Max active or idle connections in the pool (default: 10).
- `maxPooledIdleTime` (seconds) — Time before an idle connection is closed (default: 30).
- `pingCheck` (boolean) — Whether to validate connections before use (default: false).
- `poolCapacity` (number) — Maximum number of connections to be kept available (default: no limit).
See the official documentation of SAP HANA Client for more details as well as the `extra` properties: [Node.js Connection Properties](https://help.sap.com/docs/SAP_HANA_CLIENT/f1b440ded6144a54ada97ff95dac7adf/4fe9978ebac44f35b9369ef5a4a26f4c.html).
## Data Source Options example
Here is a small example of data source options for mysql:

View File

@ -193,18 +193,11 @@ await timber.remove()
- for **SAP Hana**
```
npm install @sap/hana-client
npm install hdb-pool
```
_SAP Hana support made possible by the sponsorship of [Neptune Software](https://www.neptune-software.com/)._
`npm install @sap/hana-client --save`
- for **Google Cloud Spanner**
```
npm install @google-cloud/spanner --save
```
`npm install @google-cloud/spanner --save`
Provide authentication credentials to your application code
by setting the environment variable `GOOGLE_APPLICATION_CREDENTIALS`:

18
package-lock.json generated
View File

@ -58,7 +58,6 @@
"gulp-sourcemaps": "^3.0.0",
"gulp-typescript": "^6.0.0-alpha.1",
"gulpclass": "^0.2.0",
"hdb-pool": "^0.1.6",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"mocha": "^10.8.2",
@ -95,9 +94,8 @@
},
"peerDependencies": {
"@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0",
"@sap/hana-client": "^2.12.25",
"@sap/hana-client": "^2.14.22",
"better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"hdb-pool": "^0.1.6",
"ioredis": "^5.0.4",
"mongodb": "^5.8.0 || ^6.0.0",
"mssql": "^9.1.1 || ^10.0.1 || ^11.0.1",
@ -123,9 +121,6 @@
"better-sqlite3": {
"optional": true
},
"hdb-pool": {
"optional": true
},
"ioredis": {
"optional": true
},
@ -8384,17 +8379,6 @@
"node": ">= 0.4"
}
},
"node_modules/hdb-pool": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/hdb-pool/-/hdb-pool-0.1.6.tgz",
"integrity": "sha512-8VZOLn1EHamm1NmTFQj2iqjVcfonYIsD7F5DU2bz2N+gF+knp6/MbAVeRXkJtya717IBkPeA5iv0/1iPuYo4ZA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",

View File

@ -136,7 +136,6 @@
"gulp-sourcemaps": "^3.0.0",
"gulp-typescript": "^6.0.0-alpha.1",
"gulpclass": "^0.2.0",
"hdb-pool": "^0.1.6",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"mocha": "^10.8.2",
@ -167,9 +166,8 @@
},
"peerDependencies": {
"@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0",
"@sap/hana-client": "^2.12.25",
"@sap/hana-client": "^2.14.22",
"better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"hdb-pool": "^0.1.6",
"ioredis": "^5.0.4",
"mongodb": "^5.8.0 || ^6.0.0",
"mssql": "^9.1.1 || ^10.0.1 || ^11.0.1",
@ -195,9 +193,6 @@
"better-sqlite3": {
"optional": true
},
"hdb-pool": {
"optional": true
},
"ioredis": {
"optional": true
},

View File

@ -19,13 +19,12 @@ export interface SapConnectionOptions
/**
* The driver objects
* This defaults to require("hdb-pool")
* This defaults to require("@sap/hana-client")
*/
readonly driver?: any
/**
* The driver objects
* This defaults to require("@sap/hana-client")
* @deprecated Use {@link driver} instead.
*/
readonly hanaClientDriver?: any
@ -33,30 +32,64 @@ export interface SapConnectionOptions
* Pool options.
*/
readonly pool?: {
/**
* Maximum number of open connections created by the pool, each of which
* may be in the pool waiting to be reused or may no longer be in the
* pool and actively being used (default: 10).
*/
readonly maxConnectedOrPooled?: number
/**
* Defines the maximum time, in seconds, that connections are allowed to
* remain in the pool before being marked for eviction (default: 30).
*/
readonly maxPooledIdleTime?: number
/**
* Determines whether or not the pooled connection should be tested for
* viability before being reused (default: false).
*/
readonly pingCheck?: boolean
/**
* Maximum number of connections allowed to be in the pool, waiting to
* be reused (default: 0, no limit).
*/
readonly poolCapacity?: number
/**
* Max number of connections.
* @deprecated Use {@link maxConnectedOrPooled} instead.
*/
readonly max?: number
/**
* Minimum number of connections.
* @deprecated Obsolete, no alternative exists.
*/
readonly min?: number
/**
* Maximum number of waiting requests allowed. (default=0, no limit).
* Maximum number of waiting requests allowed.
* @deprecated Obsolete, no alternative exists.
*/
readonly maxWaitingRequests?: number
/**
* Max milliseconds a request will wait for a resource before timing out. (default=5000)
* Max milliseconds a request will wait for a resource before timing out.
* @deprecated Obsolete, no alternative exists.
*/
readonly requestTimeout?: number
/**
* How often to run resource timeout checks. (default=0, disabled)
* How often to run resource timeout checks.
* @deprecated Obsolete, no alternative exists.
*/
readonly checkInterval?: number
/**
* Idle timeout
* Idle timeout (in milliseconds).
* @deprecated Use {@link maxPooledIdleTime} (in seconds) instead .
*/
readonly idleTimeout?: number
@ -66,6 +99,4 @@ export interface SapConnectionOptions
*/
readonly poolErrorHandler?: (err: any) => any
}
readonly poolSize?: never
}

View File

@ -1,3 +1,4 @@
import { promisify } from "node:util"
import {
ColumnType,
ConnectionIsNotSetError,
@ -13,21 +14,20 @@ import { TypeORMError } from "../../error/TypeORMError"
import { ColumnMetadata } from "../../metadata/ColumnMetadata"
import { PlatformTools } from "../../platform/PlatformTools"
import { RdbmsSchemaBuilder } from "../../schema-builder/RdbmsSchemaBuilder"
import { View } from "../../schema-builder/view/View"
import { ApplyValueTransformers } from "../../util/ApplyValueTransformers"
import { DateUtils } from "../../util/DateUtils"
import { InstanceChecker } from "../../util/InstanceChecker"
import { OrmUtils } from "../../util/OrmUtils"
import { Driver } from "../Driver"
import { DriverUtils } from "../DriverUtils"
import { CteCapabilities } from "../types/CteCapabilities"
import { DataTypeDefaults } from "../types/DataTypeDefaults"
import { MappedColumnTypes } from "../types/MappedColumnTypes"
import { ReplicationMode } from "../types/ReplicationMode"
import { UpsertType } from "../types/UpsertType"
import { SapConnectionOptions } from "./SapConnectionOptions"
import { SapQueryRunner } from "./SapQueryRunner"
import { ReplicationMode } from "../types/ReplicationMode"
import { DriverUtils } from "../DriverUtils"
import { View } from "../../schema-builder/view/View"
import { InstanceChecker } from "../../util/InstanceChecker"
import { UpsertType } from "../types/UpsertType"
/**
* Organizes communication with SAP Hana DBMS.
*
@ -44,24 +44,24 @@ export class SapDriver implements Driver {
connection: DataSource
/**
* Hana Pool instance.
* SAP HANA Client Pool instance.
*/
client: any
/**
* Hana Client streaming extension.
* SAP HANA Client streaming extension.
*/
streamClient: any
/**
* Pool for master database.
*/
master: any
/**
* Pool for slave databases.
* Used in replication.
* Function handling errors thrown by drivers pool.
*/
slaves: any[] = []
poolErrorHandler: (error: any) => void
// -------------------------------------------------------------------------
// Public Implemented Properties
@ -225,7 +225,7 @@ export class SapDriver implements Driver {
/**
* Max length allowed by SAP HANA for aliases (identifiers).
* @see https://help.sap.com/viewer/4fe29514fd584807ac9f2a04f6754767/2.0.03/en-US/20a760537519101497e3cfe07b348f3c.html
* @see https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-sql-reference-guide/system-limitations
*/
maxAliasLength = 128
@ -259,54 +259,62 @@ export class SapDriver implements Driver {
*/
async connect(): Promise<void> {
// HANA connection info
const dbParams = {
hostName: this.options.host,
const connectionOptions: any = {
host: this.options.host,
port: this.options.port,
userName: this.options.username,
user: this.options.username,
password: this.options.password,
...this.options.extra,
database: this.options.database,
currentSchema: this.options.schema,
encrypt: this.options.encrypt,
sslValidateCertificate: this.options.sslValidateCertificate,
key: this.options.key,
cert: this.options.cert,
ca: this.options.ca,
}
if (this.options.database) dbParams.databaseName = this.options.database
if (this.options.schema) dbParams.currentSchema = this.options.schema
if (this.options.encrypt) dbParams.encrypt = this.options.encrypt
if (this.options.sslValidateCertificate)
dbParams.validateCertificate = this.options.sslValidateCertificate
if (this.options.key) dbParams.key = this.options.key
if (this.options.cert) dbParams.cert = this.options.cert
if (this.options.ca) dbParams.ca = this.options.ca
Object.keys(connectionOptions).forEach((key) => {
if (connectionOptions[key] === undefined) {
delete connectionOptions[key]
}
})
Object.assign(connectionOptions, this.options.extra ?? {})
// pool options
const options: any = {
min:
this.options.pool && this.options.pool.min
? this.options.pool.min
: 1,
max:
this.options.pool && this.options.pool.max
? this.options.pool.max
: 10,
const poolOptions: any = {
maxConnectedOrPooled:
this.options.pool?.maxConnectedOrPooled ??
this.options.pool?.max ??
this.options.poolSize ??
10,
maxPooledIdleTime:
this.options.pool?.maxPooledIdleTime ??
(this.options.pool?.idleTimeout
? this.options.pool.idleTimeout / 1000
: 30),
}
if (this.options.pool?.pingCheck) {
poolOptions.pingCheck = this.options.pool.pingCheck
}
if (this.options.pool?.poolCapacity) {
poolOptions.poolCapacity = this.options.pool.poolCapacity
}
if (this.options.pool && this.options.pool.checkInterval)
options.checkInterval = this.options.pool.checkInterval
if (this.options.pool && this.options.pool.maxWaitingRequests)
options.maxWaitingRequests = this.options.pool.maxWaitingRequests
if (this.options.pool && this.options.pool.requestTimeout)
options.requestTimeout = this.options.pool.requestTimeout
if (this.options.pool && this.options.pool.idleTimeout)
options.idleTimeout = this.options.pool.idleTimeout
const { logger } = this.connection
const poolErrorHandler =
options.poolErrorHandler ||
((error: any) =>
logger.log("warn", `SAP Hana pool raised an error. ${error}`))
this.client.eventEmitter.on("poolError", poolErrorHandler)
this.poolErrorHandler =
this.options.pool?.poolErrorHandler ??
((error: Error) => {
this.connection.logger.log(
"warn",
`SAP HANA pool raised an error: ${error}`,
)
})
// create the pool
this.master = this.client.createPool(dbParams, options)
try {
this.master = this.client.createPool(connectionOptions, poolOptions)
} catch (error) {
this.poolErrorHandler(error)
throw error
}
const queryRunner = this.createQueryRunner("master")
@ -338,7 +346,40 @@ export class SapDriver implements Driver {
}
this.master = undefined
await pool.clear()
try {
await promisify(pool.clear).call(pool)
} catch (error) {
this.poolErrorHandler(error)
throw error
}
}
/**
* Obtains a new database connection to a master server.
* Used for replication.
* If replication is not setup then returns default connection's database connection.
*/
async obtainMasterConnection(): Promise<any> {
const pool = this.master
if (!pool) {
throw new TypeORMError("Driver not Connected")
}
try {
return await promisify(pool.getConnection).call(pool)
} catch (error) {
this.poolErrorHandler(error)
throw error
}
}
/**
* Obtains a new database connection to a slave server.
* Used for replication.
* If replication is not setup then returns master (default) connection's database connection.
*/
async obtainSlaveConnection(): Promise<any> {
return this.obtainMasterConnection()
}
/**
@ -724,28 +765,6 @@ export class SapDriver implements Driver {
return type
}
/**
* Obtains a new database connection to a master server.
* Used for replication.
* If replication is not setup then returns default connection's database connection.
*/
obtainMasterConnection(): Promise<any> {
if (!this.master) {
throw new TypeORMError("Driver not Connected")
}
return this.master.getConnection()
}
/**
* Obtains a new database connection to a slave server.
* Used for replication.
* If replication is not setup then returns master (default) connection's database connection.
*/
obtainSlaveConnection(): Promise<any> {
return this.obtainMasterConnection()
}
/**
* Creates generated map of values generated or returned by database after INSERT query.
*/
@ -851,22 +870,19 @@ export class SapDriver implements Driver {
* If driver dependency is not given explicitly, then try to load it via "require".
*/
protected loadDependencies(): void {
try {
const client = this.options.driver || PlatformTools.load("hdb-pool")
const client = this.options.driver ?? this.options.hanaClientDriver
if (client) {
this.client = client
} catch (e) {
// todo: better error for browser env
throw new DriverPackageNotInstalledError("SAP Hana", "hdb-pool")
return
}
try {
if (!this.options.hanaClientDriver) {
PlatformTools.load("@sap/hana-client")
this.streamClient = PlatformTools.load(
"@sap/hana-client/extension/Stream",
)
}
} catch (e) {
this.client = PlatformTools.load("@sap/hana-client")
this.streamClient = PlatformTools.load(
"@sap/hana-client/extension/Stream",
)
} catch {
// todo: better error for browser env
throw new DriverPackageNotInstalledError(
"SAP Hana",

View File

@ -1,4 +1,4 @@
import { promisify } from "util"
import { promisify } from "node:util"
import { ObjectLiteral } from "../../common/ObjectLiteral"
import { QueryFailedError, TypeORMError } from "../../error"
import { QueryRunnerAlreadyReleasedError } from "../../error/QueryRunnerAlreadyReleasedError"
@ -85,14 +85,20 @@ export class SapQueryRunner extends BaseQueryRunner implements QueryRunner {
* Releases used database connection.
* You cannot use query runner methods once its released.
*/
release(): Promise<void> {
async release(): Promise<void> {
this.isReleased = true
if (this.databaseConnection) {
return this.driver.master.release(this.databaseConnection)
// return the connection back to the pool
try {
await promisify(this.databaseConnection.disconnect).call(
this.databaseConnection,
)
} catch (error) {
this.driver.poolErrorHandler(error)
throw error
}
}
return Promise.resolve()
}
/**
@ -168,14 +174,12 @@ export class SapQueryRunner extends BaseQueryRunner implements QueryRunner {
*/
async setAutoCommit(options: { status: "on" | "off" }) {
const connection = await this.connect()
const execute = promisify(connection.exec.bind(connection))
connection.setAutoCommit(options.status === "on")
const query = `SET TRANSACTION AUTOCOMMIT DDL ${options.status.toUpperCase()};`
const query = `SET TRANSACTION AUTOCOMMIT DDL ${options.status.toUpperCase()}`
this.driver.connection.logger.logQuery(query, [], this)
try {
await execute(query)
await promisify(connection.exec).call(connection, query)
} catch (error) {
throw new QueryFailedError(query, [], error)
}
@ -270,26 +274,20 @@ export class SapQueryRunner extends BaseQueryRunner implements QueryRunner {
if (isInsertQuery) {
const lastIdQuery = `SELECT CURRENT_IDENTITY_VALUE() FROM "SYS"."DUMMY"`
this.driver.connection.logger.logQuery(lastIdQuery, [], this)
const identityValueResult = await new Promise<any>(
(ok, fail) => {
databaseConnection.exec(
lastIdQuery,
(err: any, raw: any) =>
err
? fail(
new QueryFailedError(
lastIdQuery,
[],
err,
),
)
: ok(raw),
)
},
)
try {
const identityValueResult: [
{ "CURRENT_IDENTITY_VALUE()": unknown },
] = await promisify(databaseConnection.exec).call(
databaseConnection,
lastIdQuery,
)
result.raw = identityValueResult[0]["CURRENT_IDENTITY_VALUE()"]
result.records = identityValueResult
result.raw =
identityValueResult[0]["CURRENT_IDENTITY_VALUE()"]
result.records = identityValueResult
} catch (error) {
throw new QueryFailedError(lastIdQuery, [], error)
}
}
} catch (err) {
this.driver.connection.logger.logQueryError(
@ -311,7 +309,7 @@ export class SapQueryRunner extends BaseQueryRunner implements QueryRunner {
} finally {
// Never forget to drop the statement we reserved
if (statement?.drop) {
await new Promise<void>((ok) => statement.drop(() => ok()))
await promisify(statement.drop).call(statement)
}
await broadcasterResult.wait()
@ -343,11 +341,15 @@ export class SapQueryRunner extends BaseQueryRunner implements QueryRunner {
let resultSet: any
const cleanup = async () => {
if (resultSet) {
await promisify(resultSet.close).call(resultSet)
const originalStatement = statement
const originalResultSet = resultSet
statement = null
resultSet = null
if (originalResultSet) {
await promisify(originalResultSet.close).call(originalResultSet)
}
if (statement) {
await promisify(statement.drop).call(statement)
if (originalStatement) {
await promisify(originalStatement.drop).call(originalStatement)
}
release()
}
@ -367,20 +369,20 @@ export class SapQueryRunner extends BaseQueryRunner implements QueryRunner {
const stream =
this.driver.streamClient.createObjectStream(resultSet)
stream.on("end", async () => {
await cleanup()
onEnd?.()
})
stream.on("error", async (error: Error) => {
if (onEnd) {
stream.on("end", onEnd)
}
stream.on("error", (error: Error) => {
this.driver.connection.logger.logQueryError(
error,
query,
parameters,
this,
)
await cleanup()
onError?.(error)
})
stream.on("close", cleanup)
return stream
} catch (error) {

View File

@ -60,9 +60,6 @@ export class PlatformTools {
case "@sap/hana-client/extension/Stream":
return require("@sap/hana-client/extension/Stream")
case "hdb-pool":
return require("hdb-pool")
/**
* mysql
*/

View File

@ -0,0 +1,72 @@
import "reflect-metadata"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../../utils/test-utils"
import { DataSource } from "../../../../src/data-source/DataSource"
import { expect } from "chai"
import { SapDriver } from "../../../../src/driver/sap/SapDriver"
import { QueryRunner } from "../../../../src"
import { ConnectionPool } from "@sap/hana-client"
describe("driver > sap > connection pool", () => {
let dataSources: DataSource[]
before(
async () =>
(dataSources = await createTestingConnections({
enabledDrivers: ["sap"],
driverSpecific: {
pool: {
maxConnectedOrPooled: 3,
},
},
})),
)
beforeEach(() => reloadTestingDatabases(dataSources))
after(() => closeTestingConnections(dataSources))
it("should be managed correctly", () =>
Promise.all(
dataSources.map(async (dataSource) => {
const poolClient = (dataSource.driver as SapDriver)
.master as ConnectionPool
expect(poolClient.getInUseCount()).to.equal(0)
expect(poolClient.getPooledCount()).to.be.at.most(3)
const queryRunners: QueryRunner[] = []
for (let i = 0; i < 3; i++) {
const queryRunner = dataSource.createQueryRunner()
queryRunners.push(queryRunner)
// the QueryRunner takes a connection from the pool once the first query is executed
await queryRunner.sql`SELECT * FROM SYS.DUMMY`
}
expect(poolClient.getInUseCount()).to.equal(3)
expect(poolClient.getPooledCount()).to.equal(0)
const newQueryRunner = dataSource.createQueryRunner()
await expect(newQueryRunner.connect()).to.be.rejectedWith(
"Unable to create connection, the maxConnectedOrPool limit has been reached",
)
await newQueryRunner.release()
const oldQueryRunner = queryRunners.pop()!
await oldQueryRunner.release()
expect(poolClient.getInUseCount()).to.equal(2)
expect(poolClient.getPooledCount()).to.equal(1)
const queryRunner = dataSource.createQueryRunner()
queryRunners.push(queryRunner)
await expect(queryRunner.connect()).to.be.fulfilled
expect(poolClient.getInUseCount()).to.equal(3)
expect(poolClient.getPooledCount()).to.equal(0)
for (const queryRunner of queryRunners) {
await queryRunner.release()
}
expect(poolClient.getInUseCount()).to.equal(0)
expect(poolClient.getPooledCount()).to.equal(3)
}),
))
})