Skip to content

Commit 7438ad5

Browse files
tianzhouclaudeCopilot
authored
feat: add lazy database connections (#235)
* feat: add lazy database connections Add `lazy` option to source configuration that defers database connection until the first query. This avoids unnecessary connection overhead for remote databases when queries may not occur during a session. - Add `lazy?: boolean` to SourceConfig type - Implement lazy connection logic in ConnectorManager with race condition protection via pending connection promises - Update tool handlers to call ensureConnected() before getting connector - Update dbhub.toml.example and documentation Closes #234 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update src/connectors/manager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f303799 commit 7438ad5

File tree

7 files changed

+143
-13
lines changed

7 files changed

+143
-13
lines changed

dbhub.toml.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
3838
# dsn = "postgres://dev_user:dev_password@dev-db.internal.company.com:5432/myapp_dev?sslmode=require"
3939
# connection_timeout = 30
4040

41-
# Production PostgreSQL (behind SSH bastion)
41+
# Production PostgreSQL (behind SSH bastion, lazy connection)
4242
# [[sources]]
4343
# id = "prod_pg"
4444
# dsn = "postgres://app_user:secure_password@10.0.1.100:5432/myapp_prod?sslmode=require"
45+
# lazy = true # Defer connection until first query (avoids startup overhead for remote DBs)
4546
# connection_timeout = 30
4647
# # SSH tunnel configuration
4748
# ssh_host = "bastion.company.com"
@@ -254,6 +255,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
254255
# id = "unique_id" # Required: unique identifier
255256
# description = "..." # Optional: human-readable description
256257
# dsn = "..." # Connection string (or use individual params below)
258+
# lazy = true # Defer connection until first query (default: false)
257259
#
258260
# DSN Formats:
259261
# PostgreSQL: postgres://user:pass@host:5432/database?sslmode=require

docs/config/toml.mdx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ TOML configuration is the recommended way to configure DBHub for multi-database
88

99
TOML configuration enables:
1010
- **Multi-database support**: Connect to multiple databases from a single DBHub instance
11-
- **Per-source settings**: Configure timeouts, SSL, and SSH tunnels individually per database
11+
- **Per-source settings**: Configure timeouts, SSL, SSH tunnels, and lazy connections individually per database
1212
- **Per-tool settings**: Apply different restrictions (readonly, max_rows) per tool
1313
- **Custom tools**: Define reusable, parameterized SQL operations as MCP tools
1414

@@ -180,6 +180,29 @@ Sources define database connections. Each source represents a database that DBHu
180180
</Note>
181181
</ParamField>
182182

183+
### lazy
184+
185+
<ParamField path="lazy" type="boolean" default="false">
186+
Defer database connection until the first query. When enabled, the connection is not established at server startup but instead when a tool first accesses this source.
187+
188+
This is useful for remote databases (e.g., RDS, Cloud SQL) where you want to avoid unnecessary connection overhead if the database may not be queried during a session.
189+
190+
```toml
191+
[[sources]]
192+
id = "production"
193+
dsn = "postgres://user:pass@prod-db.example.com:5432/mydb"
194+
lazy = true # Connection deferred until first query
195+
```
196+
197+
**Startup behavior:**
198+
- Without `lazy`: Connection established immediately, errors shown at startup
199+
- With `lazy = true`: Source registered but not connected, connection errors appear on first query
200+
201+
<Note>
202+
When a lazy source is accessed for the first time, there will be a brief delay as the connection is established. Connection errors will appear in tool responses rather than at startup.
203+
</Note>
204+
</ParamField>
205+
183206
### sslmode
184207

185208
<ParamField path="sslmode" type="string">
@@ -473,6 +496,7 @@ Tools define MCP tools (like `execute_sql`) with specific execution settings. To
473496
[[sources]]
474497
id = "production"
475498
dsn = "postgres://user:pass@db.prod.internal:5432/mydb"
499+
lazy = true # Defer connection until first query
476500
connection_timeout = 60
477501
query_timeout = 30
478502
sslmode = "require"
@@ -544,6 +568,7 @@ default = 10
544568
|-------|------|----------|-------------|
545569
| `id` | string || Unique source identifier |
546570
| `dsn` | string || Database connection string |
571+
| `lazy` | boolean || Defer connection until first query (default: `false`) |
547572
| `connection_timeout` | number || Connection timeout (seconds) |
548573
| `query_timeout` | number || Query timeout (seconds) |
549574
| `sslmode` | string || SSL mode: `disable`, `require` |

src/connectors/manager.ts

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export class ConnectorManager {
2020
private sourceConfigs: Map<string, SourceConfig> = new Map(); // Store original source configs
2121
private sourceIds: string[] = []; // Ordered list of source IDs (first is default)
2222

23+
// Lazy connection support
24+
private lazySources: Map<string, SourceConfig> = new Map(); // Sources pending lazy connection
25+
private pendingConnections: Map<string, Promise<void>> = new Map(); // Prevent race conditions
26+
2327
constructor() {
2428
if (!managerInstance) {
2529
managerInstance = this;
@@ -35,12 +39,95 @@ export class ConnectorManager {
3539
throw new Error("No sources provided");
3640
}
3741

38-
console.error(`Connecting to ${sources.length} database source(s)...`);
42+
const eagerSources = sources.filter(s => !s.lazy);
43+
const lazySources = sources.filter(s => s.lazy);
44+
45+
if (eagerSources.length > 0) {
46+
console.error(`Connecting to ${eagerSources.length} database source(s)...`);
47+
}
3948

40-
// Connect to each source
41-
for (const source of sources) {
49+
// Connect to eager sources immediately
50+
for (const source of eagerSources) {
4251
await this.connectSource(source);
4352
}
53+
54+
// Register lazy sources without connecting
55+
for (const source of lazySources) {
56+
this.registerLazySource(source);
57+
}
58+
}
59+
60+
/**
61+
* Register a lazy source without establishing connection
62+
* Connection will be established on first use via ensureConnected()
63+
*/
64+
private registerLazySource(source: SourceConfig): void {
65+
const sourceId = source.id;
66+
const dsn = buildDSNFromSource(source);
67+
68+
console.error(` - ${sourceId}: ${redactDSN(dsn)} (lazy, will connect on first use)`);
69+
70+
// Store config for later connection
71+
this.lazySources.set(sourceId, source);
72+
this.sourceConfigs.set(sourceId, source);
73+
this.sourceIds.push(sourceId);
74+
}
75+
76+
/**
77+
* Ensure a source is connected (handles lazy connection on demand)
78+
* Safe to call multiple times - uses promise-based deduplication so concurrent calls share the same connection attempt
79+
*/
80+
async ensureConnected(sourceId?: string): Promise<void> {
81+
const id = sourceId || this.sourceIds[0];
82+
83+
// Already connected
84+
if (this.connectors.has(id)) {
85+
return;
86+
}
87+
88+
// Not a lazy source - must be an error
89+
const lazySource = this.lazySources.get(id);
90+
if (!lazySource) {
91+
if (sourceId) {
92+
throw new Error(
93+
`Source '${sourceId}' not found. Available sources: ${this.sourceIds.join(", ")}`
94+
);
95+
} else {
96+
throw new Error("No sources configured. Call connectWithSources() first.");
97+
}
98+
}
99+
100+
// Check if connection is already in progress (race condition prevention)
101+
const pending = this.pendingConnections.get(id);
102+
if (pending) {
103+
return pending;
104+
}
105+
106+
// Start connection and track the promise
107+
const connectionPromise = (async () => {
108+
try {
109+
console.error(`Lazy connecting to source '${id}'...`);
110+
await this.connectSource(lazySource);
111+
// Remove from lazy sources after successful connection
112+
this.lazySources.delete(id);
113+
} finally {
114+
// Clean up pending connection tracker
115+
this.pendingConnections.delete(id);
116+
}
117+
})();
118+
119+
this.pendingConnections.set(id, connectionPromise);
120+
return connectionPromise;
121+
}
122+
123+
/**
124+
* Static method to ensure a source is connected (for tool handlers)
125+
*/
126+
static async ensureConnected(sourceId?: string): Promise<void> {
127+
if (!managerInstance) {
128+
throw new Error("ConnectorManager not initialized");
129+
}
130+
return managerInstance.ensureConnected(sourceId);
44131
}
45132

46133
/**
@@ -138,7 +225,11 @@ export class ConnectorManager {
138225

139226
// Store connector
140227
this.connectors.set(sourceId, connector);
141-
this.sourceIds.push(sourceId);
228+
229+
// Only add to sourceIds if not already present (lazy sources are pre-registered)
230+
if (!this.sourceIds.includes(sourceId)) {
231+
this.sourceIds.push(sourceId);
232+
}
142233

143234
// Store source config (for API exposure)
144235
this.sourceConfigs.set(sourceId, source);
@@ -171,6 +262,8 @@ export class ConnectorManager {
171262
this.connectors.clear();
172263
this.sshTunnels.clear();
173264
this.sourceConfigs.clear();
265+
this.lazySources.clear();
266+
this.pendingConnections.clear();
174267
this.sourceIds = [];
175268
}
176269

@@ -242,7 +335,7 @@ export class ConnectorManager {
242335
* @param sourceId - Source ID. If not provided, returns default (first) source config
243336
*/
244337
getSourceConfig(sourceId?: string): SourceConfig | null {
245-
if (this.connectors.size === 0) {
338+
if (this.sourceIds.length === 0) {
246339
return null;
247340
}
248341
const id = sourceId || this.sourceIds[0];

src/tools/custom-tool-handler.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,37 +168,40 @@ export function createCustomToolHandler(toolConfig: ToolConfig) {
168168
// 1. Validate arguments against Zod schema
169169
const validatedArgs = zodSchema.parse(args);
170170

171-
// 2. Get connector for the specified source
171+
// 2. Ensure source is connected (handles lazy connections)
172+
await ConnectorManager.ensureConnected(toolConfig.source);
173+
174+
// 3. Get connector for the specified source
172175
const connector = ConnectorManager.getCurrentConnector(toolConfig.source);
173176

174-
// 3. Build execute options from tool configuration
177+
// 4. Build execute options from tool configuration
175178
const executeOptions = {
176179
readonly: toolConfig.readonly,
177180
maxRows: toolConfig.max_rows,
178181
};
179182

180-
// 4. Check if SQL is allowed based on readonly mode
183+
// 5. Check if SQL is allowed based on readonly mode
181184
const isReadonly = executeOptions.readonly === true;
182185
if (isReadonly && !isAllowedInReadonlyMode(toolConfig.statement, connector.id)) {
183186
errorMessage = createReadonlyViolationMessage(toolConfig.name, toolConfig.source, connector.id);
184187
success = false;
185188
return createToolErrorResponse(errorMessage, "READONLY_VIOLATION");
186189
}
187190

188-
// 5. Map parameters to array format for SQL execution
191+
// 6. Map parameters to array format for SQL execution
189192
paramValues = mapArgumentsToArray(
190193
toolConfig.parameters,
191194
validatedArgs
192195
);
193196

194-
// 6. Execute SQL with parameters
197+
// 7. Execute SQL with parameters
195198
const result = await connector.executeSQL(
196199
toolConfig.statement,
197200
executeOptions,
198201
paramValues
199202
);
200203

201-
// 7. Build response data
204+
// 8. Build response data
202205
const responseData = {
203206
rows: result.rows,
204207
count: result.rowCount,

src/tools/execute-sql.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ export function createExecuteSqlToolHandler(sourceId?: string) {
5353
let result: any;
5454

5555
try {
56+
// Ensure source is connected (handles lazy connections)
57+
await ConnectorManager.ensureConnected(sourceId);
58+
5659
// Get connector for the specified source (or default)
5760
const connector = ConnectorManager.getCurrentConnector(sourceId);
5861
const actualSourceId = connector.getId();

src/tools/search-objects.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,9 @@ export function createSearchDatabaseObjectsToolHandler(sourceId?: string) {
481481
let errorMessage: string | undefined;
482482

483483
try {
484+
// Ensure source is connected (handles lazy connections)
485+
await ConnectorManager.ensureConnected(sourceId);
486+
484487
const connector = ConnectorManager.getCurrentConnector(sourceId);
485488

486489
// Tool is already registered, so it's enabled (no need to check)

src/types/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface SourceConfig extends ConnectionParams, SSHConfig {
4646
connection_timeout?: number; // Connection timeout in seconds
4747
query_timeout?: number; // Query timeout in seconds (PostgreSQL, MySQL, MariaDB, SQL Server)
4848
init_script?: string; // Optional SQL script to run on connection (for demo mode or initialization)
49+
lazy?: boolean; // Defer connection until first query (default: false)
4950
}
5051

5152
/**

0 commit comments

Comments
 (0)