Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion packages/core/src/cores/pool-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { BoundedPoolCache } from './pool-cache';
import {
BoundedPoolCache,
credentialedCacheKey,
nonSecretFingerprint,
} from './pool-cache';

describe('nonSecretFingerprint / credentialedCacheKey', () => {
it('fingerprints stably without echoing the secret', () => {
const a = nonSecretFingerprint('secret-a');
const b = nonSecretFingerprint('secret-b');
expect(a).toMatch(/^[0-9a-f]+$/);
expect(a).not.toBe(b);
expect(a).not.toContain('secret');
expect(nonSecretFingerprint('secret-a')).toBe(a);
});

it('partitions Oracle-style keys by username and password', () => {
const base = {
connectionString: 'db.example:1521/ORCL',
username: 'demo_a',
};
const correct = credentialedCacheKey({ ...base, password: 'right' });
const wrongPw = credentialedCacheKey({ ...base, password: 'wrong' });
const otherUser = credentialedCacheKey({
...base,
username: 'demo_b',
password: 'right',
});
expect(correct).not.toBe(wrongPw);
expect(correct).not.toBe(otherUser);
expect(correct).toContain('db.example:1521/ORCL');
expect(correct).not.toContain('right');
});

it('partitions ClickHouse-style keys by database and credentials', () => {
const url = 'http://ch:8123';
const dbA = credentialedCacheKey({
connectionString: url,
username: 'default',
password: 'p',
database: 'analytics',
});
const dbB = credentialedCacheKey({
connectionString: url,
username: 'default',
password: 'p',
database: 'staging',
});
const otherUser = credentialedCacheKey({
connectionString: url,
username: 'reader',
password: 'p',
database: 'analytics',
});
expect(dbA).not.toBe(dbB);
expect(dbA).not.toBe(otherUser);
});
});

describe('BoundedPoolCache', () => {
it('reuses existing pools and refreshes LRU order', async () => {
Expand Down Expand Up @@ -39,4 +96,26 @@ describe('BoundedPoolCache', () => {
expect(cache.size()).toBe(0);
expect(dispose).toHaveBeenCalledTimes(2);
});

it('dedupes concurrent getOrCreate for the same key', async () => {
const dispose = vi.fn(async () => {});
const cache = new BoundedPoolCache<{ id: number }>(dispose, { maxPools: 4, idleTtlMs: 60_000 });
let creates = 0;
const create = () =>
new Promise<{ id: number }>((resolve) => {
creates += 1;
setTimeout(() => resolve({ id: creates }), 20);
});

const [a, b, c] = await Promise.all([
cache.getOrCreate('x', create),
cache.getOrCreate('x', create),
cache.getOrCreate('x', create),
]);
expect(creates).toBe(1);
expect(a).toBe(b);
expect(b).toBe(c);
expect(cache.size()).toBe(1);
expect(dispose).not.toHaveBeenCalled();
});
});
69 changes: 63 additions & 6 deletions packages/core/src/cores/pool-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,39 @@ export type PoolDispose<T> = (pool: T) => void | Promise<void>;
const DEFAULT_MAX_POOLS = 16;
const DEFAULT_IDLE_TTL_MS = 10 * 60 * 1000;

/**
* Non-secret fingerprint for cache partitioning (djb2). Distinguishes different
* passwords without embedding plaintext in logs or debug output.
*/
export function nonSecretFingerprint(value: string): string {
let h = 5381;
for (let i = 0; i < value.length; i++) {
h = ((h << 5) + h) ^ value.charCodeAt(i);
}
return (h >>> 0).toString(16);
}

/**
* Cache key for adapters whose connection string omits username/password/database
* (Oracle Easy Connect, ClickHouse HTTP URL). Including a password fingerprint
* prevents wrong-password reuse of an existing authenticated pool/client.
*/
export function credentialedCacheKey(parts: {
connectionString: string;
username?: string;
password?: string;
database?: string;
}): string {
const u = parts.username ?? '';
const db = parts.database ?? '';
const pw = nonSecretFingerprint(parts.password ?? '');
return `${parts.connectionString}|u:${u}|db:${db}|pw:${pw}`;
}

export class BoundedPoolCache<T> {
private readonly entries = new Map<string, { pool: T; lastUsed: number }>();
/** In-flight creations so concurrent getOrCreate for the same key share one pool. */
private readonly pending = new Map<string, Promise<T>>();
private readonly maxPools: number;
private readonly idleTtlMs: number;
private readonly dispose: PoolDispose<T>;
Expand Down Expand Up @@ -48,13 +79,39 @@ export class BoundedPoolCache<T> {
const existing = this.peek(key);
if (existing !== undefined) return existing;

while (this.entries.size >= this.maxPools) {
await this.evictOldest();
}
const inflight = this.pending.get(key);
if (inflight) return inflight;

const createPromise = (async () => {
try {
// Another waiter may have finished while we were scheduling.
const raced = this.peek(key);
if (raced !== undefined) return raced;

while (this.entries.size >= this.maxPools) {
await this.evictOldest();
}

const again = this.peek(key);
if (again !== undefined) return again;

const pool = await create();
const winner = this.entries.get(key);
if (winner) {
// Duplicate create (should be rare); keep the stored pool, dispose ours.
await Promise.resolve(this.dispose(pool));
winner.lastUsed = Date.now();
return winner.pool;
}
this.entries.set(key, { pool, lastUsed: Date.now() });
return pool;
} finally {
this.pending.delete(key);
}
})();

const pool = await create();
this.entries.set(key, { pool, lastUsed: Date.now() });
return pool;
this.pending.set(key, createPromise);
return createPromise;
}

async clear(): Promise<void> {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ export { MigrationModule } from './modules/migration.module';
export { ConnectionFactory } from './cores/connection-factory';
export { DriverDetector } from './cores/driver-detector';
export { assertSafeIdentifier } from './cores/sql-identifier';
export { BoundedPoolCache, disposePoolEndOrClose } from './cores/pool-cache';
export {
BoundedPoolCache,
disposePoolEndOrClose,
nonSecretFingerprint,
credentialedCacheKey,
} from './cores/pool-cache';
export { setupDb2ClientEnv } from './providers/db2/db2.env';
export {
resolveFkReferencedColumns,
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/providers/clickHouse/clickhouse.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createRequire } from 'node:module';
import { ConnectionOptions, DriverAdapter } from '../../interfaces/schema-provider.interface';
import { credentialedCacheKey } from '../../cores/pool-cache';

const nodeRequire = createRequire(import.meta.url);

Expand All @@ -25,17 +26,29 @@ class ClickHouseAdapter implements DriverAdapter {
}

async acquire(connectionString: string, options: ConnectionOptions, _pooled: boolean): Promise<any> {
if (this.clients.has(connectionString)) return this.clients.get(connectionString)!;
// ClickHouse connection strings are only http(s)://host:port — username,
// password, and database are separate createClient options. Keying by URL
// alone reused the first client for every database/user on that host.
const username = options.username || 'default';
const password = options.password || '';
const database = options.database || options.schema || 'default';
const clientKey = credentialedCacheKey({
connectionString,
username,
password,
database,
});
if (this.clients.has(clientKey)) return this.clients.get(clientKey)!;
const { createClient } = this.load();
const client = createClient({
url: connectionString,
username: options.username || 'default',
password: options.password || '',
database: options.database || options.schema || 'default',
username,
password,
database,
request_timeout: options.timeout?.queryMs ?? 30000,
compression: { response: true, request: false },
});
this.clients.set(connectionString, client);
this.clients.set(clientKey, client);
return client;
}

Expand Down
24 changes: 15 additions & 9 deletions packages/core/src/providers/oracle/oracle.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createRequire } from 'node:module';
import { ConnectionOptions, DriverAdapter } from '../../interfaces/schema-provider.interface';
import { BoundedPoolCache, disposePoolEndOrClose } from '../../cores/pool-cache';
import {
BoundedPoolCache,
credentialedCacheKey,
disposePoolEndOrClose,
} from '../../cores/pool-cache';

const nodeRequire = createRequire(import.meta.url);

Expand Down Expand Up @@ -49,14 +53,16 @@ class OracleAdapter implements DriverAdapter {
return { _type: 'tx', conn };
}

// Key pools by user AND connect string. Oracle's connect string
// (host:port/service) carries no username — it's passed separately — and
// source/target routinely connect to the SAME service as DIFFERENT users
// (schema-per-user model, e.g. demo_a vs demo_b). Keying by connect string
// alone made the second user silently reuse the first user's pool, so its
// catalog queries ran under the wrong account and returned zero objects —
// the compare then reported every target object as ADDED.
const poolKey = `${options.username ?? ''}@${connectionString}`;
// Key pools by user, connect string, AND password fingerprint. Oracle's
// Easy Connect string (host:port/service) carries no username/password —
// those are passed separately. Username alone was not enough: a later
// request with the same user@service but a wrong/rotated password reused
// the authenticated pool (auth bypass / stale credentials).
const poolKey = credentialedCacheKey({
connectionString,
username: options.username || '',
password: options.password || '',
});
const pool = await this.pools.getOrCreate(poolKey, () =>
oracledb.createPool({
user: options.username || '',
Expand Down
Loading