BREAKING CHANGE(core): Refactor to v3: introduce modular core/domain architecture, plugin system, observability and strict TypeScript configuration; remove legacy classes
This commit is contained in:
306
ts/core/connection/circuit-breaker.ts
Normal file
306
ts/core/connection/circuit-breaker.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Circuit breaker states
|
||||
*/
|
||||
export enum CircuitState {
|
||||
/** Circuit is closed, requests flow normally */
|
||||
CLOSED = 'closed',
|
||||
|
||||
/** Circuit is open, requests are rejected immediately */
|
||||
OPEN = 'open',
|
||||
|
||||
/** Circuit is half-open, testing if service recovered */
|
||||
HALF_OPEN = 'half_open',
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker configuration
|
||||
*/
|
||||
export interface CircuitBreakerConfig {
|
||||
/** Number of failures before opening circuit */
|
||||
failureThreshold: number;
|
||||
|
||||
/** Number of successes in half-open state before closing */
|
||||
successThreshold: number;
|
||||
|
||||
/** Time in milliseconds circuit stays open before attempting half-open */
|
||||
timeout: number;
|
||||
|
||||
/** Time window in milliseconds for counting failures */
|
||||
rollingWindow: number;
|
||||
|
||||
/** Whether circuit breaker is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default circuit breaker configuration
|
||||
*/
|
||||
export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = {
|
||||
failureThreshold: 5,
|
||||
successThreshold: 2,
|
||||
timeout: 60000, // 1 minute
|
||||
rollingWindow: 10000, // 10 seconds
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Circuit breaker error thrown when circuit is open
|
||||
*/
|
||||
export class CircuitBreakerOpenError extends Error {
|
||||
constructor(
|
||||
public readonly circuitName: string,
|
||||
public readonly nextAttemptTime: Date
|
||||
) {
|
||||
super(
|
||||
`Circuit breaker "${circuitName}" is OPEN. Next attempt at ${nextAttemptTime.toISOString()}`
|
||||
);
|
||||
this.name = 'CircuitBreakerOpenError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure record for tracking
|
||||
*/
|
||||
interface FailureRecord {
|
||||
timestamp: number;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker for preventing cascading failures
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const breaker = new CircuitBreaker('elasticsearch', {
|
||||
* failureThreshold: 5,
|
||||
* timeout: 60000,
|
||||
* });
|
||||
*
|
||||
* try {
|
||||
* const result = await breaker.execute(async () => {
|
||||
* return await someElasticsearchOperation();
|
||||
* });
|
||||
* } catch (error) {
|
||||
* if (error instanceof CircuitBreakerOpenError) {
|
||||
* // Circuit is open, handle gracefully
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CircuitBreaker {
|
||||
private config: CircuitBreakerConfig;
|
||||
private state: CircuitState = CircuitState.CLOSED;
|
||||
private failures: FailureRecord[] = [];
|
||||
private successCount = 0;
|
||||
private openedAt?: number;
|
||||
private nextAttemptTime?: number;
|
||||
|
||||
constructor(
|
||||
private name: string,
|
||||
config: Partial<CircuitBreakerConfig> = {}
|
||||
) {
|
||||
this.config = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation through the circuit breaker
|
||||
*/
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (!this.config.enabled) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
// Check circuit state
|
||||
this.updateState();
|
||||
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
const nextAttempt = this.nextAttemptTime
|
||||
? new Date(this.nextAttemptTime)
|
||||
: new Date(Date.now() + this.config.timeout);
|
||||
throw new CircuitBreakerOpenError(this.name, nextAttempt);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onFailure(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful operation
|
||||
*/
|
||||
private onSuccess(): void {
|
||||
this.removeOldFailures();
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
this.successCount++;
|
||||
|
||||
if (this.successCount >= this.config.successThreshold) {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed operation
|
||||
*/
|
||||
private onFailure(error: Error): void {
|
||||
this.failures.push({
|
||||
timestamp: Date.now(),
|
||||
error,
|
||||
});
|
||||
|
||||
this.removeOldFailures();
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
// Any failure in half-open state opens the circuit immediately
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
} else if (this.state === CircuitState.CLOSED) {
|
||||
// Check if we've exceeded failure threshold
|
||||
if (this.failures.length >= this.config.failureThreshold) {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update circuit state based on time
|
||||
*/
|
||||
private updateState(): void {
|
||||
if (this.state === CircuitState.OPEN && this.nextAttemptTime) {
|
||||
if (Date.now() >= this.nextAttemptTime) {
|
||||
this.transitionTo(CircuitState.HALF_OPEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to a new state
|
||||
*/
|
||||
private transitionTo(newState: CircuitState): void {
|
||||
const previousState = this.state;
|
||||
this.state = newState;
|
||||
|
||||
switch (newState) {
|
||||
case CircuitState.OPEN:
|
||||
this.openedAt = Date.now();
|
||||
this.nextAttemptTime = Date.now() + this.config.timeout;
|
||||
this.successCount = 0;
|
||||
break;
|
||||
|
||||
case CircuitState.HALF_OPEN:
|
||||
this.successCount = 0;
|
||||
break;
|
||||
|
||||
case CircuitState.CLOSED:
|
||||
this.failures = [];
|
||||
this.successCount = 0;
|
||||
this.openedAt = undefined;
|
||||
this.nextAttemptTime = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
if (previousState !== newState) {
|
||||
this.onStateChange(previousState, newState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove failures outside the rolling window
|
||||
*/
|
||||
private removeOldFailures(): void {
|
||||
const cutoff = Date.now() - this.config.rollingWindow;
|
||||
this.failures = this.failures.filter((f) => f.timestamp >= cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback when state changes (can be overridden)
|
||||
*/
|
||||
protected onStateChange(from: CircuitState, to: CircuitState): void {
|
||||
// Override in subclass or use getState() to monitor
|
||||
console.log(`Circuit breaker "${this.name}" transitioned from ${from} to ${to}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current circuit state
|
||||
*/
|
||||
getState(): CircuitState {
|
||||
this.updateState();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit statistics
|
||||
*/
|
||||
getStats(): {
|
||||
state: CircuitState;
|
||||
failureCount: number;
|
||||
successCount: number;
|
||||
openedAt?: Date;
|
||||
nextAttemptTime?: Date;
|
||||
} {
|
||||
this.removeOldFailures();
|
||||
this.updateState();
|
||||
|
||||
return {
|
||||
state: this.state,
|
||||
failureCount: this.failures.length,
|
||||
successCount: this.successCount,
|
||||
...(this.openedAt && { openedAt: new Date(this.openedAt) }),
|
||||
...(this.nextAttemptTime && { nextAttemptTime: new Date(this.nextAttemptTime) }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually open the circuit
|
||||
*/
|
||||
open(): void {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually close the circuit
|
||||
*/
|
||||
close(): void {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the circuit breaker
|
||||
*/
|
||||
reset(): void {
|
||||
this.failures = [];
|
||||
this.successCount = 0;
|
||||
this.openedAt = undefined;
|
||||
this.nextAttemptTime = undefined;
|
||||
this.state = CircuitState.CLOSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is open
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
this.updateState();
|
||||
return this.state === CircuitState.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is closed
|
||||
*/
|
||||
isClosed(): boolean {
|
||||
this.updateState();
|
||||
return this.state === CircuitState.CLOSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is half-open
|
||||
*/
|
||||
isHalfOpen(): boolean {
|
||||
this.updateState();
|
||||
return this.state === CircuitState.HALF_OPEN;
|
||||
}
|
||||
}
|
||||
358
ts/core/connection/connection-manager.ts
Normal file
358
ts/core/connection/connection-manager.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { Client as ElasticClient } from '@elastic/elasticsearch';
|
||||
import { ElasticsearchConfig } from '../config/types.js';
|
||||
import { HealthChecker, HealthCheckResult, HealthStatus } from './health-check.js';
|
||||
import { CircuitBreaker } from './circuit-breaker.js';
|
||||
import { Logger, defaultLogger } from '../observability/logger.js';
|
||||
import { MetricsCollector, defaultMetricsCollector } from '../observability/metrics.js';
|
||||
import { ConnectionError, ClusterUnavailableError } from '../errors/elasticsearch-error.js';
|
||||
|
||||
/**
|
||||
* Connection manager configuration
|
||||
*/
|
||||
export interface ConnectionManagerConfig extends ElasticsearchConfig {
|
||||
/** Enable health checks */
|
||||
enableHealthCheck?: boolean;
|
||||
|
||||
/** Enable circuit breaker */
|
||||
enableCircuitBreaker?: boolean;
|
||||
|
||||
/** Logger instance */
|
||||
logger?: Logger;
|
||||
|
||||
/** Metrics collector */
|
||||
metricsCollector?: MetricsCollector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection manager for Elasticsearch client
|
||||
*
|
||||
* Provides:
|
||||
* - Singleton client instance
|
||||
* - Connection pooling
|
||||
* - Health monitoring
|
||||
* - Circuit breaker pattern
|
||||
* - Automatic reconnection
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const manager = ElasticsearchConnectionManager.getInstance({
|
||||
* nodes: ['http://localhost:9200'],
|
||||
* auth: { type: 'basic', username: 'elastic', password: 'changeme' }
|
||||
* });
|
||||
*
|
||||
* const client = manager.getClient();
|
||||
* await client.search({ index: 'my-index', query: { match_all: {} } });
|
||||
* ```
|
||||
*/
|
||||
export class ElasticsearchConnectionManager {
|
||||
private static instance: ElasticsearchConnectionManager | null = null;
|
||||
|
||||
private client: ElasticClient;
|
||||
private healthChecker: HealthChecker;
|
||||
private circuitBreaker: CircuitBreaker;
|
||||
private logger: Logger;
|
||||
private metrics: MetricsCollector;
|
||||
private config: ConnectionManagerConfig;
|
||||
private isInitialized = false;
|
||||
private connectionCount = 0;
|
||||
|
||||
private constructor(config: ConnectionManagerConfig) {
|
||||
this.config = config;
|
||||
this.logger = config.logger || defaultLogger.child('connection-manager');
|
||||
this.metrics = config.metricsCollector || defaultMetricsCollector;
|
||||
|
||||
// Initialize Elasticsearch client
|
||||
this.client = this.createClient(config);
|
||||
|
||||
// Initialize health checker
|
||||
this.healthChecker = new HealthChecker(this.client, {
|
||||
interval: config.pool?.maxIdleTime || 30000,
|
||||
timeout: config.request?.timeout || 5000,
|
||||
checkClusterHealth: true,
|
||||
});
|
||||
|
||||
// Initialize circuit breaker
|
||||
this.circuitBreaker = new CircuitBreaker('elasticsearch', {
|
||||
enabled: config.enableCircuitBreaker !== false,
|
||||
failureThreshold: 5,
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
this.logger.info('Elasticsearch connection manager created', {
|
||||
nodes: config.nodes,
|
||||
poolEnabled: !!config.pool,
|
||||
healthCheckEnabled: config.enableHealthCheck !== false,
|
||||
circuitBreakerEnabled: config.enableCircuitBreaker !== false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(config?: ConnectionManagerConfig): ElasticsearchConnectionManager {
|
||||
if (!ElasticsearchConnectionManager.instance) {
|
||||
if (!config) {
|
||||
throw new Error('Configuration required for first initialization');
|
||||
}
|
||||
ElasticsearchConnectionManager.instance = new ElasticsearchConnectionManager(config);
|
||||
}
|
||||
return ElasticsearchConnectionManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton instance (useful for testing)
|
||||
*/
|
||||
static resetInstance(): void {
|
||||
if (ElasticsearchConnectionManager.instance) {
|
||||
ElasticsearchConnectionManager.instance.destroy();
|
||||
ElasticsearchConnectionManager.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Elasticsearch client with configuration
|
||||
*/
|
||||
private createClient(config: ConnectionManagerConfig): ElasticClient {
|
||||
const nodes = Array.isArray(config.nodes) ? config.nodes : [config.nodes];
|
||||
|
||||
const clientConfig: any = {
|
||||
nodes,
|
||||
};
|
||||
|
||||
// Authentication
|
||||
if (config.auth) {
|
||||
switch (config.auth.type) {
|
||||
case 'basic':
|
||||
clientConfig.auth = {
|
||||
username: config.auth.username,
|
||||
password: config.auth.password,
|
||||
};
|
||||
break;
|
||||
case 'apiKey':
|
||||
clientConfig.auth = {
|
||||
apiKey: config.auth.apiKey,
|
||||
};
|
||||
break;
|
||||
case 'bearer':
|
||||
clientConfig.auth = {
|
||||
bearer: config.auth.token,
|
||||
};
|
||||
break;
|
||||
case 'cloud':
|
||||
clientConfig.cloud = {
|
||||
id: config.auth.id,
|
||||
};
|
||||
if (config.auth.apiKey) {
|
||||
clientConfig.auth = { apiKey: config.auth.apiKey };
|
||||
} else if (config.auth.username && config.auth.password) {
|
||||
clientConfig.auth = {
|
||||
username: config.auth.username,
|
||||
password: config.auth.password,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// TLS configuration
|
||||
if (config.tls) {
|
||||
clientConfig.tls = config.tls;
|
||||
}
|
||||
|
||||
// Request configuration
|
||||
if (config.request) {
|
||||
clientConfig.requestTimeout = config.request.timeout;
|
||||
clientConfig.maxRetries = config.request.maxRetries;
|
||||
clientConfig.compression = config.request.compression;
|
||||
}
|
||||
|
||||
// Discovery/sniffing configuration
|
||||
if (config.discovery) {
|
||||
clientConfig.sniffOnStart = config.discovery.sniffOnStart;
|
||||
clientConfig.sniffInterval = config.discovery.interval;
|
||||
clientConfig.sniffOnConnectionFault = config.discovery.sniffOnConnectionFault;
|
||||
}
|
||||
|
||||
// Proxy
|
||||
if (config.proxy) {
|
||||
clientConfig.proxy = config.proxy;
|
||||
}
|
||||
|
||||
// Custom agent
|
||||
if (config.agent) {
|
||||
clientConfig.agent = config.agent;
|
||||
}
|
||||
|
||||
return new ElasticClient(clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connection manager
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.info('Initializing connection manager...');
|
||||
|
||||
// Test connection
|
||||
await this.client.ping();
|
||||
this.logger.info('Successfully connected to Elasticsearch');
|
||||
|
||||
// Start health checks if enabled
|
||||
if (this.config.enableHealthCheck !== false) {
|
||||
this.healthChecker.startPeriodicChecks((result) => {
|
||||
this.onHealthChange(result);
|
||||
});
|
||||
this.logger.info('Health checks started');
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
this.metrics.activeConnections.set(1);
|
||||
|
||||
this.logger.info('Connection manager initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize connection manager', error as Error);
|
||||
throw new ConnectionError(
|
||||
'Failed to connect to Elasticsearch cluster',
|
||||
{
|
||||
operation: 'initialize',
|
||||
},
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Elasticsearch client
|
||||
*/
|
||||
getClient(): ElasticClient {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Connection manager not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
// Check circuit breaker
|
||||
if (this.circuitBreaker.isOpen()) {
|
||||
const stats = this.circuitBreaker.getStats();
|
||||
throw new ClusterUnavailableError(
|
||||
`Elasticsearch cluster unavailable. Circuit breaker open until ${stats.nextAttemptTime?.toISOString()}`,
|
||||
{
|
||||
circuitState: stats.state,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.connectionCount++;
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation through circuit breaker
|
||||
*/
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return this.circuitBreaker.execute(operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check callback
|
||||
*/
|
||||
private onHealthChange(result: HealthCheckResult): void {
|
||||
this.logger.info('Cluster health changed', {
|
||||
status: result.status,
|
||||
clusterHealth: result.clusterHealth,
|
||||
activeNodes: result.activeNodes,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
});
|
||||
|
||||
// Open circuit breaker if unhealthy
|
||||
if (result.status === HealthStatus.UNHEALTHY) {
|
||||
this.logger.warn('Cluster unhealthy, opening circuit breaker');
|
||||
this.circuitBreaker.open();
|
||||
} else if (result.status === HealthStatus.HEALTHY && this.circuitBreaker.isOpen()) {
|
||||
this.logger.info('Cluster recovered, closing circuit breaker');
|
||||
this.circuitBreaker.close();
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
this.metrics.activeConnections.set(result.available ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform health check
|
||||
*/
|
||||
async healthCheck(): Promise<HealthCheckResult> {
|
||||
return this.healthChecker.check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current health status
|
||||
*/
|
||||
getHealthStatus(): HealthStatus {
|
||||
return this.healthChecker.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is healthy
|
||||
*/
|
||||
isHealthy(): boolean {
|
||||
return this.healthChecker.isHealthy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is available
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.healthChecker.isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker state
|
||||
*/
|
||||
getCircuitState(): string {
|
||||
return this.circuitBreaker.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getStats(): {
|
||||
initialized: boolean;
|
||||
connectionCount: number;
|
||||
healthStatus: HealthStatus;
|
||||
circuitState: string;
|
||||
lastHealthCheck?: HealthCheckResult;
|
||||
} {
|
||||
return {
|
||||
initialized: this.isInitialized,
|
||||
connectionCount: this.connectionCount,
|
||||
healthStatus: this.healthChecker.getStatus(),
|
||||
circuitState: this.circuitBreaker.getState(),
|
||||
lastHealthCheck: this.healthChecker.getLastCheckResult(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup and close connections
|
||||
*/
|
||||
async destroy(): Promise<void> {
|
||||
this.logger.info('Destroying connection manager...');
|
||||
|
||||
// Stop health checks
|
||||
this.healthChecker.destroy();
|
||||
|
||||
// Close Elasticsearch client
|
||||
try {
|
||||
await this.client.close();
|
||||
this.logger.info('Elasticsearch client closed');
|
||||
} catch (error) {
|
||||
this.logger.error('Error closing Elasticsearch client', error as Error);
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
this.metrics.activeConnections.set(0);
|
||||
|
||||
this.logger.info('Connection manager destroyed');
|
||||
}
|
||||
}
|
||||
304
ts/core/connection/health-check.ts
Normal file
304
ts/core/connection/health-check.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { Client as ElasticClient } from '@elastic/elasticsearch';
|
||||
|
||||
/**
|
||||
* Health status
|
||||
*/
|
||||
export enum HealthStatus {
|
||||
HEALTHY = 'healthy',
|
||||
DEGRADED = 'degraded',
|
||||
UNHEALTHY = 'unhealthy',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster health status from Elasticsearch
|
||||
*/
|
||||
export enum ClusterHealth {
|
||||
GREEN = 'green',
|
||||
YELLOW = 'yellow',
|
||||
RED = 'red',
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check result
|
||||
*/
|
||||
export interface HealthCheckResult {
|
||||
/** Overall health status */
|
||||
status: HealthStatus;
|
||||
|
||||
/** Cluster health from Elasticsearch */
|
||||
clusterHealth?: ClusterHealth;
|
||||
|
||||
/** Whether the cluster is available */
|
||||
available: boolean;
|
||||
|
||||
/** Response time in milliseconds */
|
||||
responseTimeMs?: number;
|
||||
|
||||
/** Number of active nodes */
|
||||
activeNodes?: number;
|
||||
|
||||
/** Error if health check failed */
|
||||
error?: Error;
|
||||
|
||||
/** Timestamp of health check */
|
||||
timestamp: Date;
|
||||
|
||||
/** Additional details */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check configuration
|
||||
*/
|
||||
export interface HealthCheckConfig {
|
||||
/** Interval between health checks in milliseconds */
|
||||
interval: number;
|
||||
|
||||
/** Timeout for health check requests */
|
||||
timeout: number;
|
||||
|
||||
/** Number of consecutive failures before marking unhealthy */
|
||||
unhealthyThreshold: number;
|
||||
|
||||
/** Number of consecutive successes before marking healthy */
|
||||
healthyThreshold: number;
|
||||
|
||||
/** Whether to check cluster health */
|
||||
checkClusterHealth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default health check configuration
|
||||
*/
|
||||
export const DEFAULT_HEALTH_CHECK_CONFIG: HealthCheckConfig = {
|
||||
interval: 30000, // 30 seconds
|
||||
timeout: 5000, // 5 seconds
|
||||
unhealthyThreshold: 3,
|
||||
healthyThreshold: 2,
|
||||
checkClusterHealth: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Health checker for Elasticsearch cluster
|
||||
*/
|
||||
export class HealthChecker {
|
||||
private config: HealthCheckConfig;
|
||||
private consecutiveFailures = 0;
|
||||
private consecutiveSuccesses = 0;
|
||||
private currentStatus: HealthStatus = HealthStatus.UNKNOWN;
|
||||
private lastCheckResult?: HealthCheckResult;
|
||||
private checkInterval?: NodeJS.Timeout;
|
||||
private isChecking = false;
|
||||
|
||||
constructor(
|
||||
private client: ElasticClient,
|
||||
config: Partial<HealthCheckConfig> = {}
|
||||
) {
|
||||
this.config = { ...DEFAULT_HEALTH_CHECK_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single health check
|
||||
*/
|
||||
async check(): Promise<HealthCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Ping the cluster
|
||||
const pingResponse = await Promise.race([
|
||||
this.client.ping(),
|
||||
this.timeout(this.config.timeout),
|
||||
]);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const available = pingResponse === true || (pingResponse as any).statusCode === 200;
|
||||
|
||||
if (!available) {
|
||||
throw new Error('Cluster ping failed');
|
||||
}
|
||||
|
||||
// Check cluster health if enabled
|
||||
let clusterHealth: ClusterHealth | undefined;
|
||||
let activeNodes: number | undefined;
|
||||
|
||||
if (this.config.checkClusterHealth) {
|
||||
try {
|
||||
const healthResponse = await this.client.cluster.health({
|
||||
timeout: `${this.config.timeout}ms`,
|
||||
});
|
||||
|
||||
clusterHealth = healthResponse.status as ClusterHealth;
|
||||
activeNodes = healthResponse.number_of_nodes;
|
||||
} catch (error) {
|
||||
// Cluster health check failed, but ping succeeded
|
||||
// Mark as degraded
|
||||
this.consecutiveSuccesses = 0;
|
||||
this.consecutiveFailures++;
|
||||
|
||||
const result: HealthCheckResult = {
|
||||
status: HealthStatus.DEGRADED,
|
||||
available: true,
|
||||
responseTimeMs: responseTime,
|
||||
error: error as Error,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.lastCheckResult = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Success!
|
||||
this.consecutiveFailures = 0;
|
||||
this.consecutiveSuccesses++;
|
||||
|
||||
// Determine status based on cluster health
|
||||
let status: HealthStatus;
|
||||
if (clusterHealth === ClusterHealth.GREEN) {
|
||||
status = HealthStatus.HEALTHY;
|
||||
} else if (clusterHealth === ClusterHealth.YELLOW) {
|
||||
status = HealthStatus.DEGRADED;
|
||||
} else if (clusterHealth === ClusterHealth.RED) {
|
||||
status = HealthStatus.UNHEALTHY;
|
||||
} else {
|
||||
// No cluster health, but ping succeeded
|
||||
status =
|
||||
this.consecutiveSuccesses >= this.config.healthyThreshold
|
||||
? HealthStatus.HEALTHY
|
||||
: HealthStatus.DEGRADED;
|
||||
}
|
||||
|
||||
this.currentStatus = status;
|
||||
|
||||
const result: HealthCheckResult = {
|
||||
status,
|
||||
clusterHealth,
|
||||
available: true,
|
||||
responseTimeMs: responseTime,
|
||||
activeNodes,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.lastCheckResult = result;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.consecutiveSuccesses = 0;
|
||||
this.consecutiveFailures++;
|
||||
|
||||
const status =
|
||||
this.consecutiveFailures >= this.config.unhealthyThreshold
|
||||
? HealthStatus.UNHEALTHY
|
||||
: HealthStatus.DEGRADED;
|
||||
|
||||
this.currentStatus = status;
|
||||
|
||||
const result: HealthCheckResult = {
|
||||
status,
|
||||
available: false,
|
||||
error: error as Error,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.lastCheckResult = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic health checks
|
||||
*/
|
||||
startPeriodicChecks(onHealthChange?: (result: HealthCheckResult) => void): void {
|
||||
if (this.checkInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
const performCheck = async () => {
|
||||
if (this.isChecking) return;
|
||||
|
||||
this.isChecking = true;
|
||||
try {
|
||||
const previousStatus = this.currentStatus;
|
||||
const result = await this.check();
|
||||
|
||||
if (onHealthChange && result.status !== previousStatus) {
|
||||
onHealthChange(result);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error already handled in check()
|
||||
} finally {
|
||||
this.isChecking = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Perform initial check
|
||||
performCheck();
|
||||
|
||||
// Schedule periodic checks
|
||||
this.checkInterval = setInterval(performCheck, this.config.interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic health checks
|
||||
*/
|
||||
stopPeriodicChecks(): void {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current health status
|
||||
*/
|
||||
getStatus(): HealthStatus {
|
||||
return this.currentStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last health check result
|
||||
*/
|
||||
getLastCheckResult(): HealthCheckResult | undefined {
|
||||
return this.lastCheckResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is healthy
|
||||
*/
|
||||
isHealthy(): boolean {
|
||||
return this.currentStatus === HealthStatus.HEALTHY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is available
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.lastCheckResult?.available ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset health check state
|
||||
*/
|
||||
reset(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
this.consecutiveSuccesses = 0;
|
||||
this.currentStatus = HealthStatus.UNKNOWN;
|
||||
this.lastCheckResult = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a timeout promise
|
||||
*/
|
||||
private timeout(ms: number): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Health check timeout after ${ms}ms`)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stopPeriodicChecks();
|
||||
}
|
||||
}
|
||||
15
ts/core/connection/index.ts
Normal file
15
ts/core/connection/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Connection management for Elasticsearch client
|
||||
*
|
||||
* This module provides:
|
||||
* - Connection pooling and lifecycle management
|
||||
* - Health monitoring with periodic checks
|
||||
* - Circuit breaker pattern for fault tolerance
|
||||
* - Automatic reconnection
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './health-check.js';
|
||||
export * from './circuit-breaker.js';
|
||||
export * from './connection-manager.js';
|
||||
Reference in New Issue
Block a user