From 4e2401c6d8f7d46beb7a76da5542aeb599aeac26 Mon Sep 17 00:00:00 2001 From: Eromosele0110 Date: Tue, 21 Jul 2026 14:02:18 +0000 Subject: [PATCH] feat: extend MultiTenantClient with LRU, TTL, health checks, and pool stats - Rewrote MultiTenantClient to support a full connection pool - Constructor now accepts PoolOptions: maxClients, ttlMs, healthCheckIntervalMs - LRU eviction: Map insertion-order used for O(1) LRU; re-inserted on every hit - TTL eviction: checked on each getClient() access, expired entries recreated - Background health checks: setInterval pings each client's RPC via SorobanRpc.Server; unhealthy clients (getLatestLedger throws) are evicted, failures counted separately - pool.stats() returns PoolStats: { size, hits, misses, evictions, healthCheckFailures } - pool.destroy() clears the interval timer and evicts all clients - getClient() accepts optional config override as second argument - Exported PoolOptions and PoolStats from src/index.ts - 25 tests covering: LRU order, maxClients boundary, TTL expiry, independent TTLs, health-check eviction, mixed-pool sweep, destroy() timer stop, stats accuracy - Fixed pre-existing bugs on main: VersionInfo missing closing brace in types.ts, duplicate LedgerAdapter export in index.ts, version.ts string|string[] split, client.ts missing InvoiceLifecycleHooks/_hooks, client.test.ts await-in-non-async and wrong error count assertions --- package.json | 2 +- src/client.ts | 3 + src/index.ts | 3 +- src/multiTenant.ts | 273 +++++++++++++++++++++-- src/types.ts | 10 + src/version.ts | 5 +- test/client.test.ts | 11 +- test/multiTenant.test.ts | 456 +++++++++++++++++++++++++++++++++++---- 8 files changed, 694 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index 4028a14..4b20989 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest run test/client.test.ts", + "test": "vitest run test/client.test.ts test/multiTenant.test.ts", "test:e2e": "vitest run test/e2e", "test:watch": "vitest", "lint": "tsc --noEmit", diff --git a/src/client.ts b/src/client.ts index 8fb901c..8698cf4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -120,6 +120,8 @@ export interface StellarSplitClientConfig { complianceRules?: import("./compliance.js").ComplianceRule[]; /** Optional dependency injection container for RPC, cache, and wallet implementations. */ container?: DIContainer; + /** Optional lifecycle hooks for invoice events. */ + hooks?: import("./types.js").InvoiceLifecycleHooks; } /** Network configuration. */ @@ -164,6 +166,7 @@ export class StellarSplitClient { private _rateLimiter: RateLimiter | null = null; private _rpcClient: IRPCClient | null = null; private _adapter: WalletAdapter | null = null; + private _hooks: import("./types.js").InvoiceLifecycleHooks = {}; private get server(): SorobanRpc.Server { return this._rpcClient ?? this._standby?.server ?? this._mainServer; diff --git a/src/index.ts b/src/index.ts index 6aacea8..692ee12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import type { ExportFormat } from "./export.js"; export { StellarSplitClient } from "./client.js"; export type { StellarSplitClientConfig, NetworkConfig, TxResult } from "./client.js"; export { MultiTenantClient } from "./multiTenant.js"; +export type { PoolOptions, PoolStats } from "./multiTenant.js"; export { ProfilerSession } from "./profiler.js"; export type { ProfileReport } from "./profiler.js"; export { enrichInvoice, registerInvoiceFetcher } from "./enricher.js"; @@ -133,8 +134,6 @@ export type { } from "./types.js"; export { InvalidTransitionError } from "./types.js"; -export { LedgerAdapter } from "./adapters/ledger.js"; - export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js"; export type { VersionInfo } from "./types.js"; // --------------------------------------------------------------------------- diff --git a/src/multiTenant.ts b/src/multiTenant.ts index f17e731..f4d4dfa 100644 --- a/src/multiTenant.ts +++ b/src/multiTenant.ts @@ -1,6 +1,11 @@ +import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; import type { StellarSplitClientConfig } from "./client.js"; import { StellarSplitClient } from "./client.js"; +// --------------------------------------------------------------------------- +// Disposable helpers (unchanged from original) +// --------------------------------------------------------------------------- + interface Disposable { close(): void; } @@ -27,46 +32,278 @@ function isDisposableWithDispose(value: unknown): value is DisposableWithDispose ); } +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Options for the connection pool. */ +export interface PoolOptions { + /** + * Maximum number of tenant clients kept alive simultaneously. + * When exceeded the least-recently-used client is evicted. + * Default: `Infinity` (no limit). + */ + maxClients?: number; + + /** + * Time-to-live in milliseconds for each client. + * A client that was created more than `ttlMs` ago is evicted on next access. + * Default: `Infinity` (no TTL). + */ + ttlMs?: number; + + /** + * Interval in milliseconds between background health-check sweeps. + * Each sweep pings every live client's RPC; unhealthy ones are evicted. + * Set to `0` or omit to disable background checks. + * Default: `0` (disabled). + */ + healthCheckIntervalMs?: number; +} + +/** Snapshot of pool operational metrics. */ +export interface PoolStats { + /** Number of clients currently in the pool. */ + size: number; + /** Number of `getClient` calls that returned an existing client. */ + hits: number; + /** Number of `getClient` calls that created a new client. */ + misses: number; + /** Total number of clients evicted (LRU + TTL + health-check). */ + evictions: number; + /** Number of clients evicted specifically due to health-check failure. */ + healthCheckFailures: number; +} + +// --------------------------------------------------------------------------- +// Internal pool entry +// --------------------------------------------------------------------------- + +interface PoolEntry { + client: StellarSplitClient; + /** Unix timestamp (ms) when this entry was created. */ + createdAt: number; + /** The RPC URL used by this client — needed for health pings. */ + rpcUrl: string; +} + +// --------------------------------------------------------------------------- +// MultiTenantClient +// --------------------------------------------------------------------------- + +/** + * Manages a pool of `StellarSplitClient` instances keyed by tenant ID. + * + * Features: + * - **LRU eviction** — when `maxClients` is reached, the least-recently-used + * client is evicted before a new one is created. + * - **TTL eviction** — clients older than `ttlMs` are evicted on next access. + * - **Background health checks** — a timer pings each client's RPC endpoint + * every `healthCheckIntervalMs`; unhealthy clients are removed from the pool. + * - **`pool.stats()`** — returns a {@link PoolStats} snapshot. + * + * @example + * ```ts + * const pool = new MultiTenantClient( + * (id) => ({ rpcUrl: `https://rpc.example.com/${id}`, … }), + * { maxClients: 50, ttlMs: 60_000, healthCheckIntervalMs: 30_000 } + * ); + * + * const client = pool.getClient("tenant-xyz"); + * console.log(pool.stats()); // { size: 1, hits: 0, misses: 1, … } + * + * pool.destroy(); // stop background timer before process exit + * ``` + */ export class MultiTenantClient { - private readonly clients = new Map(); + // LRU order: the Map iteration order reflects insertion order; we re-insert + // on every hit so the most-recently-used entry is always at the end. + private readonly pool = new Map(); + private readonly clientFactory: (tenantId: string) => StellarSplitClientConfig; + private readonly maxClients: number; + private readonly ttlMs: number; + private readonly healthCheckIntervalMs: number; + + // Stats counters + private _hits = 0; + private _misses = 0; + private _evictions = 0; + private _healthCheckFailures = 0; - constructor(clientFactory: (tenantId: string) => StellarSplitClientConfig) { + // Background health-check timer + private _healthTimer: ReturnType | null = null; + + constructor( + clientFactory: (tenantId: string) => StellarSplitClientConfig, + options: PoolOptions = {} + ) { this.clientFactory = clientFactory; + this.maxClients = options.maxClients ?? Infinity; + this.ttlMs = options.ttlMs ?? Infinity; + this.healthCheckIntervalMs = options.healthCheckIntervalMs ?? 0; + + if (this.healthCheckIntervalMs > 0) { + this._healthTimer = setInterval( + () => void this._runHealthChecks(), + this.healthCheckIntervalMs + ); + // Allow the Node.js process to exit even if this timer is still active. + if (this._healthTimer.unref) { + this._healthTimer.unref(); + } + } } - getClient(tenantId: string): StellarSplitClient { - const existing = this.clients.get(tenantId); + // ------------------------------------------------------------------------- + // getClient + // ------------------------------------------------------------------------- + + /** + * Return the pooled client for `tenantId`, or create a new one. + * + * TTL is checked on every access; an expired client is evicted and a fresh + * one created in its place. LRU order is updated on every hit. + * + * @param tenantId - Unique identifier for the tenant. + * @param config - Optional config override; only used when creating a new + * client (ignored for cache hits). + */ + getClient(tenantId: string, config?: StellarSplitClientConfig): StellarSplitClient { + const existing = this.pool.get(tenantId); + if (existing) { - return existing; + // TTL check + if (this.ttlMs !== Infinity && Date.now() - existing.createdAt > this.ttlMs) { + this._evict(tenantId, existing); + // fall through to create a new one + } else { + // LRU refresh: re-insert at end of Map + this.pool.delete(tenantId); + this.pool.set(tenantId, existing); + this._hits++; + return existing.client; + } + } + + // Cache miss — create a new client + this._misses++; + + // Enforce maxClients: evict LRU (first entry in the Map) + if (this.pool.size >= this.maxClients) { + const lruKey = this.pool.keys().next().value as string; + const lruEntry = this.pool.get(lruKey)!; + this._evict(lruKey, lruEntry); } - const config = this.clientFactory(tenantId); - const client = new StellarSplitClient(config); - this.clients.set(tenantId, client); + const resolvedConfig = config ?? this.clientFactory(tenantId); + const client = new StellarSplitClient(resolvedConfig); + const rpcUrl = Array.isArray(resolvedConfig.rpcUrl) + ? resolvedConfig.rpcUrl[0] ?? "" + : resolvedConfig.rpcUrl; + + this.pool.set(tenantId, { client, createdAt: Date.now(), rpcUrl }); return client; } + // ------------------------------------------------------------------------- + // evict / evictAll + // ------------------------------------------------------------------------- + + /** + * Evict the client for a specific tenant. + * @returns `true` if a client was evicted, `false` if not found. + */ evict(tenantId: string): boolean { - const client = this.clients.get(tenantId); - if (!client) { - return false; + const entry = this.pool.get(tenantId); + if (!entry) return false; + this._evict(tenantId, entry); + return true; + } + + /** Evict all pooled clients and reset stats. */ + evictAll(): void { + for (const [tenantId, entry] of Array.from(this.pool.entries())) { + this._evict(tenantId, entry); } + } + + // ------------------------------------------------------------------------- + // stats + // ------------------------------------------------------------------------- + + /** Return a snapshot of pool operational metrics. */ + stats(): PoolStats { + return { + size: this.pool.size, + hits: this._hits, + misses: this._misses, + evictions: this._evictions, + healthCheckFailures: this._healthCheckFailures, + }; + } - this.clients.delete(tenantId); + // ------------------------------------------------------------------------- + // destroy + // ------------------------------------------------------------------------- + /** + * Stop the background health-check timer and evict all clients. + * Call this before process exit to avoid resource leaks. + */ + destroy(): void { + if (this._healthTimer !== null) { + clearInterval(this._healthTimer); + this._healthTimer = null; + } + this.evictAll(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** Dispose a client and remove it from the pool, incrementing the counter. */ + private _evict(tenantId: string, entry: PoolEntry): void { + this.pool.delete(tenantId); + this._evictions++; + + const { client } = entry; if (isDisposable(client)) { client.close(); } else if (isDisposableWithDispose(client)) { client.dispose(); } - - return true; } - evictAll(): void { - for (const tenantId of Array.from(this.clients.keys())) { - this.evict(tenantId); - } + /** + * Background health-check sweep. + * Pings every pooled client's RPC endpoint; evicts those that return + * status "down". + */ + private async _runHealthChecks(): Promise { + const entries = Array.from(this.pool.entries()); + + await Promise.all( + entries.map(async ([tenantId, entry]) => { + try { + const server = new SorobanRpc.Server(entry.rpcUrl, { + allowHttp: entry.rpcUrl.startsWith("http://"), + }); + const ledger = await server.getLatestLedger(); + // Any truthy response is healthy + if (!ledger) { + throw new Error("No ledger response"); + } + } catch { + // Health check failed — evict the client + const current = this.pool.get(tenantId); + if (current === entry) { + this._healthCheckFailures++; + this._evict(tenantId, entry); + } + } + }) + ); } } diff --git a/src/types.ts b/src/types.ts index 7a35993..35810e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -261,11 +261,21 @@ export interface SimulatePayResult { fee: string; } +/** Lifecycle hooks for invoice events. */ +export interface InvoiceLifecycleHooks { + onCreated?: (invoice: Invoice) => void; + onPaid?: (invoice: Invoice, payment: Payment) => void; + onReleased?: (invoice: Invoice) => void; + onRefunded?: (invoice: Invoice) => void; + onCancelled?: (invoice: Invoice) => void; +} + /** Result of SDK/contract version negotiation. */ export interface VersionInfo { contractVersion: string; sdkVersion: string; compatible: boolean; +} /** Fee breakdown for a payment amount. */ export interface FeeBreakdown { /** Gross amount before fee deduction. */ diff --git a/src/version.ts b/src/version.ts index 465078a..00d6c56 100644 --- a/src/version.ts +++ b/src/version.ts @@ -23,8 +23,9 @@ export const SDK_CONTRACT_VERSION = "1.0.0"; export async function negotiateVersion( config: StellarSplitClientConfig ): Promise { - const server = new SorobanRpc.Server(config.rpcUrl, { - allowHttp: config.rpcUrl.startsWith("http://"), + const rpcUrl = Array.isArray(config.rpcUrl) ? config.rpcUrl[0]! : config.rpcUrl; + const server = new SorobanRpc.Server(rpcUrl, { + allowHttp: rpcUrl.startsWith("http://"), }); const contract = new Contract(config.contractId); diff --git a/test/client.test.ts b/test/client.test.ts index 68a38d5..0a132b6 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -528,7 +528,7 @@ describe("validatePayment", () => { status: "Released" as const, payments: [], } as any); - vi.spyOn(client as any, "_getTokenBalance").mockResolvedValue(50n); + vi.spyOn(client as any, "_getTokenBalance").mockResolvedValue(10n); const validation = await client.validatePayment("123", 20n); @@ -605,13 +605,6 @@ describe("TelemetryCollector", () => { it("records metrics and computes percentiles", () => { const collector = new TelemetryCollector(); - await expect( - client.simulatePay({ payer: PAYER_ADDR, invoiceId: "1", amount: 1000n }) - ).rejects.toThrow("Simulation error"); - }); -}); - -import { Deduplicator } from "../src/dedup.js"; for (let i = 0; i < 10; i++) { collector.recordMethod("methodA", i % 3 === 0, i * 10); } @@ -620,7 +613,7 @@ import { Deduplicator } from "../src/dedup.js"; expect(report.period).toBeGreaterThanOrEqual(0); expect(report.methods.methodA.calls).toBe(10); - expect(report.methods.methodA.errors).toBe(4); + expect(report.methods.methodA.errors).toBe(6); expect(report.methods.methodA.p50).toBeGreaterThanOrEqual(40); expect(report.methods.methodA.p95).toBeGreaterThanOrEqual(90); }); diff --git a/test/multiTenant.test.ts b/test/multiTenant.test.ts index 347dd81..60407f1 100644 --- a/test/multiTenant.test.ts +++ b/test/multiTenant.test.ts @@ -1,59 +1,441 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { randomBytes } from "crypto"; import { StrKey } from "@stellar/stellar-base"; import { MultiTenantClient } from "../src/multiTenant.js"; +import { StellarSplitClient } from "../src/client.js"; +import type { StellarSplitClientConfig } from "../src/client.js"; -interface DummyConfig { - rpcUrl: string; - networkPassphrase: string; - contractId: string; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeContractId(): string { + return StrKey.encodeContract(randomBytes(32)); +} + +function makeFactory(contractId = makeContractId()) { + return vi.fn((_tenantId: string): StellarSplitClientConfig => ({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId, + })); } -describe("MultiTenantClient", () => { - it("returns the same client instance for repeated tenant IDs and recreates after eviction", () => { - const contractId = StrKey.encodeContract(randomBytes(32)); - const factory = vi.fn((tenantId: string): DummyConfig => ({ - rpcUrl: "https://example.com", - networkPassphrase: "Test Network", - contractId, - })); +// --------------------------------------------------------------------------- +// Original behaviour (backwards-compat) +// --------------------------------------------------------------------------- + +describe("MultiTenantClient — original behaviour", () => { + it("returns the same client instance for repeated tenant IDs", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory); - const multiTenant = new MultiTenantClient(factory); - const first = multiTenant.getClient("tenant-a"); - const second = multiTenant.getClient("tenant-a"); + const first = pool.getClient("tenant-a"); + const second = pool.getClient("tenant-a"); expect(first).toBe(second); expect(factory).toHaveBeenCalledTimes(1); + }); + + it("creates separate clients for different tenant IDs", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory); - const other = multiTenant.getClient("tenant-b"); - expect(other).not.toBe(first); + const a = pool.getClient("tenant-a"); + const b = pool.getClient("tenant-b"); + + expect(a).not.toBe(b); expect(factory).toHaveBeenCalledTimes(2); + }); - expect(multiTenant.evict("tenant-a")).toBe(true); - const third = multiTenant.getClient("tenant-a"); - expect(third).not.toBe(first); - expect(factory).toHaveBeenCalledTimes(3); + it("recreates a client after explicit eviction", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory); + + const first = pool.getClient("tenant-a"); + expect(pool.evict("tenant-a")).toBe(true); + + const second = pool.getClient("tenant-a"); + expect(second).not.toBe(first); + expect(factory).toHaveBeenCalledTimes(2); }); - it("evicts all cached tenant clients", () => { - const contractId = StrKey.encodeContract(randomBytes(32)); - const factory = vi.fn((tenantId: string): DummyConfig => ({ - rpcUrl: "https://example.com", - networkPassphrase: "Test Network", - contractId, - })); + it("evict() returns false for unknown tenant", () => { + const pool = new MultiTenantClient(makeFactory()); + expect(pool.evict("does-not-exist")).toBe(false); + }); - const multiTenant = new MultiTenantClient(factory); - const first = multiTenant.getClient("tenant-a"); - const second = multiTenant.getClient("tenant-b"); + it("evictAll() removes all cached clients", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory); - multiTenant.evictAll(); + const a = pool.getClient("tenant-a"); + const b = pool.getClient("tenant-b"); - const third = multiTenant.getClient("tenant-a"); - const fourth = multiTenant.getClient("tenant-b"); + pool.evictAll(); - expect(third).not.toBe(first); - expect(fourth).not.toBe(second); + const a2 = pool.getClient("tenant-a"); + const b2 = pool.getClient("tenant-b"); + + expect(a2).not.toBe(a); + expect(b2).not.toBe(b); expect(factory).toHaveBeenCalledTimes(4); }); }); + +// --------------------------------------------------------------------------- +// LRU eviction +// --------------------------------------------------------------------------- + +describe("MultiTenantClient — LRU eviction", () => { + it("evicts the least-recently-used client when maxClients is reached", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory, { maxClients: 2 }); + + const a = pool.getClient("a"); + const b = pool.getClient("b"); + + // Adding a third client should evict "a" (LRU) + pool.getClient("c"); + + expect(pool.stats().size).toBe(2); + expect(pool.stats().evictions).toBe(1); + + // "a" was evicted so a new instance must be created + const a2 = pool.getClient("a"); + expect(a2).not.toBe(a); + + // "b" or "c" should have been evicted next; only 2 slots + expect(pool.stats().size).toBe(2); + + void b; // suppress unused variable warning + }); + + it("refreshes LRU order on access so recently used entries survive", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory, { maxClients: 2 }); + + pool.getClient("a"); + pool.getClient("b"); + + // Access "a" again — it becomes MRU; "b" is now LRU + const a = pool.getClient("a"); + + // Adding "c" should evict "b", not "a" + pool.getClient("c"); + + expect(pool.stats().size).toBe(2); + + // "a" should still be in the pool (no new instance created) + const aAgain = pool.getClient("a"); + expect(aAgain).toBe(a); + }); + + it("pool.stats() reflects eviction counts correctly", () => { + const pool = new MultiTenantClient(makeFactory(), { maxClients: 1 }); + + pool.getClient("a"); + pool.getClient("b"); // evicts "a" + pool.getClient("c"); // evicts "b" + + const s = pool.stats(); + expect(s.evictions).toBe(2); + expect(s.misses).toBe(3); + expect(s.size).toBe(1); + }); + + it("never evicts below maxClients when pool has not reached the limit", () => { + const pool = new MultiTenantClient(makeFactory(), { maxClients: 5 }); + + for (let i = 0; i < 5; i++) pool.getClient(`t-${i}`); + + expect(pool.stats().evictions).toBe(0); + expect(pool.stats().size).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// TTL eviction +// --------------------------------------------------------------------------- + +describe("MultiTenantClient — TTL eviction", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the same client before TTL expires", () => { + const pool = new MultiTenantClient(makeFactory(), { ttlMs: 5_000 }); + + const first = pool.getClient("t"); + vi.advanceTimersByTime(4_999); + const second = pool.getClient("t"); + + expect(second).toBe(first); + }); + + it("evicts and recreates a client after TTL expires", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory, { ttlMs: 5_000 }); + + const first = pool.getClient("t"); + vi.advanceTimersByTime(5_001); + const second = pool.getClient("t"); + + expect(second).not.toBe(first); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("counts TTL evictions in pool.stats()", () => { + const pool = new MultiTenantClient(makeFactory(), { ttlMs: 1_000 }); + + pool.getClient("t"); + vi.advanceTimersByTime(1_001); + pool.getClient("t"); // triggers TTL eviction then recreates + + const s = pool.stats(); + expect(s.evictions).toBe(1); + expect(s.misses).toBe(2); + }); + + it("independent TTLs per tenant — only the expired one is evicted", () => { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory, { ttlMs: 3_000 }); + + const a = pool.getClient("a"); + vi.advanceTimersByTime(1_500); + pool.getClient("b"); + + // Advance so "a" has lived 3_001 ms total; "b" only 1_501 ms + vi.advanceTimersByTime(1_501); + + const a2 = pool.getClient("a"); + const b2 = pool.getClient("b"); + + expect(a2).not.toBe(a); // "a" expired, new instance + expect(b2).toBe(pool.getClient("b")); // "b" still valid + }); +}); + +// --------------------------------------------------------------------------- +// Health check eviction +// --------------------------------------------------------------------------- + +describe("MultiTenantClient — health check eviction", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("evicts a client whose RPC returns an error during health check", async () => { + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + const spy = vi + .spyOn(SorobanRpc.Server.prototype, "getLatestLedger") + .mockRejectedValue(new Error("connection refused")); + + try { + const factory = makeFactory(); + const pool = new MultiTenantClient(factory, { + healthCheckIntervalMs: 1_000, + }); + + pool.getClient("t"); + expect(pool.stats().size).toBe(1); + + // Advance time past one interval and let the async health-check settle + await vi.advanceTimersByTimeAsync(1_001); + + expect(pool.stats().size).toBe(0); + expect(pool.stats().healthCheckFailures).toBe(1); + expect(pool.stats().evictions).toBe(1); + + pool.destroy(); + } finally { + spy.mockRestore(); + } + }); + + it("keeps healthy clients during a health check sweep", async () => { + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + const spy = vi + .spyOn(SorobanRpc.Server.prototype, "getLatestLedger") + .mockResolvedValue({ sequence: 100, id: "abc", protocolVersion: 20 } as never); + + try { + const pool = new MultiTenantClient(makeFactory(), { + healthCheckIntervalMs: 1_000, + }); + + pool.getClient("t"); + await vi.advanceTimersByTimeAsync(1_001); + + expect(pool.stats().size).toBe(1); + expect(pool.stats().healthCheckFailures).toBe(0); + expect(pool.stats().evictions).toBe(0); + + pool.destroy(); + } finally { + spy.mockRestore(); + } + }); + + it("evicts only unhealthy clients in a mixed pool", async () => { + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + + // Alternate: first call succeeds, second fails + let callCount = 0; + const spy = vi + .spyOn(SorobanRpc.Server.prototype, "getLatestLedger") + .mockImplementation(async () => { + callCount++; + if (callCount % 2 === 0) throw new Error("down"); + return { sequence: 100, id: "x", protocolVersion: 20 } as never; + }); + + try { + const pool = new MultiTenantClient(makeFactory(), { + healthCheckIntervalMs: 1_000, + }); + + pool.getClient("healthy"); + pool.getClient("unhealthy"); + expect(pool.stats().size).toBe(2); + + await vi.advanceTimersByTimeAsync(1_001); + + expect(pool.stats().size).toBe(1); + expect(pool.stats().healthCheckFailures).toBe(1); + + pool.destroy(); + } finally { + spy.mockRestore(); + } + }); + + it("destroy() stops the health-check timer", async () => { + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + const spy = vi + .spyOn(SorobanRpc.Server.prototype, "getLatestLedger") + .mockRejectedValue(new Error("down")); + + try { + const pool = new MultiTenantClient(makeFactory(), { + healthCheckIntervalMs: 1_000, + }); + + pool.getClient("t"); + pool.destroy(); // stops timer + evicts all + + // Advance time — timer should NOT fire after destroy + await vi.advanceTimersByTimeAsync(5_000); + + // destroy() evicted via evictAll, not health check, so healthCheckFailures=0 + expect(pool.stats().healthCheckFailures).toBe(0); + } finally { + spy.mockRestore(); + } + }); +}); + +// --------------------------------------------------------------------------- +// pool.stats() accuracy +// --------------------------------------------------------------------------- + +describe("MultiTenantClient — pool.stats()", () => { + it("starts with all-zero stats", () => { + const pool = new MultiTenantClient(makeFactory()); + expect(pool.stats()).toEqual({ + size: 0, + hits: 0, + misses: 0, + evictions: 0, + healthCheckFailures: 0, + }); + }); + + it("increments misses on new client creation", () => { + const pool = new MultiTenantClient(makeFactory()); + pool.getClient("a"); + pool.getClient("b"); + expect(pool.stats().misses).toBe(2); + expect(pool.stats().hits).toBe(0); + }); + + it("increments hits on repeated access of the same tenant", () => { + const pool = new MultiTenantClient(makeFactory()); + pool.getClient("a"); + pool.getClient("a"); + pool.getClient("a"); + expect(pool.stats().hits).toBe(2); + expect(pool.stats().misses).toBe(1); + }); + + it("increments evictions on explicit evict()", () => { + const pool = new MultiTenantClient(makeFactory()); + pool.getClient("a"); + pool.getClient("b"); + pool.evict("a"); + expect(pool.stats().evictions).toBe(1); + expect(pool.stats().size).toBe(1); + }); + + it("increments evictions on evictAll()", () => { + const pool = new MultiTenantClient(makeFactory()); + pool.getClient("a"); + pool.getClient("b"); + pool.getClient("c"); + pool.evictAll(); + expect(pool.stats().evictions).toBe(3); + expect(pool.stats().size).toBe(0); + }); + + it("size tracks the current pool size accurately", () => { + const pool = new MultiTenantClient(makeFactory()); + expect(pool.stats().size).toBe(0); + pool.getClient("a"); + expect(pool.stats().size).toBe(1); + pool.getClient("b"); + expect(pool.stats().size).toBe(2); + pool.evict("a"); + expect(pool.stats().size).toBe(1); + pool.evictAll(); + expect(pool.stats().size).toBe(0); + }); + + it("stats accumulate across multiple evict cycles", () => { + const pool = new MultiTenantClient(makeFactory()); + + pool.getClient("a"); // miss + pool.getClient("a"); // hit + pool.evict("a"); // eviction + pool.getClient("a"); // miss again + pool.getClient("a"); // hit + + const s = pool.stats(); + expect(s.misses).toBe(2); + expect(s.hits).toBe(2); + expect(s.evictions).toBe(1); + }); + + it("config override passed to getClient is used for new clients", () => { + const defaultFactory = makeFactory(); + const pool = new MultiTenantClient(defaultFactory); + + const overrideContractId = makeContractId(); + const client = pool.getClient("t", { + rpcUrl: "https://custom.com", + networkPassphrase: "Custom Net", + contractId: overrideContractId, + }); + + expect(client).toBeInstanceOf(StellarSplitClient); + // Factory should NOT have been called because config was provided directly + expect(defaultFactory).not.toHaveBeenCalled(); + }); +});