feat: add repository pattern, service layer architecture, and scheduler management

- Implement repository pattern for data access layer
- Add comprehensive service layer for business logic
- Create scheduler management system with health monitoring
- Add bounded buffer utility for memory management
- Enhance security audit logging with retention policies
This commit is contained in:
2025-07-12 07:00:37 +02:00
parent e1abedb148
commit 041a1cc3ef
54 changed files with 5755 additions and 878 deletions

View File

@ -0,0 +1,374 @@
import { EventEmitter } from "node:events";
import cron from "node-cron";
/**
* Scheduler status enumeration
*/
export enum SchedulerStatus {
STOPPED = "STOPPED",
STARTING = "STARTING",
RUNNING = "RUNNING",
PAUSED = "PAUSED",
ERROR = "ERROR",
}
/**
* Scheduler configuration interface
*/
export interface SchedulerConfig {
enabled: boolean;
interval: string;
maxRetries: number;
retryDelay: number;
timeout: number;
}
/**
* Scheduler metrics interface
*/
export interface SchedulerMetrics {
totalRuns: number;
successfulRuns: number;
failedRuns: number;
lastRunAt: Date | null;
lastSuccessAt: Date | null;
lastErrorAt: Date | null;
averageRunTime: number;
currentStatus: SchedulerStatus;
}
/**
* Base abstract scheduler service class
* Provides common functionality for all schedulers
*/
export abstract class BaseSchedulerService extends EventEmitter {
protected cronJob?: cron.ScheduledTask;
protected config: SchedulerConfig;
protected status: SchedulerStatus = SchedulerStatus.STOPPED;
protected metrics: SchedulerMetrics;
protected isRunning = false;
constructor(
protected name: string,
config: Partial<SchedulerConfig> = {}
) {
super();
this.config = {
enabled: true,
interval: "*/5 * * * *", // Default: every 5 minutes
maxRetries: 3,
retryDelay: 5000,
timeout: 30000,
...config,
};
this.metrics = {
totalRuns: 0,
successfulRuns: 0,
failedRuns: 0,
lastRunAt: null,
lastSuccessAt: null,
lastErrorAt: null,
averageRunTime: 0,
currentStatus: this.status,
};
}
/**
* Abstract method that subclasses must implement
* Contains the actual scheduler logic
*/
protected abstract executeTask(): Promise<void>;
/**
* Start the scheduler
*/
async start(): Promise<void> {
if (!this.config.enabled) {
console.log(`[${this.name}] Scheduler disabled via configuration`);
return;
}
if (this.status === SchedulerStatus.RUNNING) {
console.warn(`[${this.name}] Scheduler is already running`);
return;
}
try {
this.status = SchedulerStatus.STARTING;
this.emit("statusChange", this.status);
console.log(
`[${this.name}] Starting scheduler with interval: ${this.config.interval}`
);
this.cronJob = cron.schedule(
this.config.interval,
() => this.runWithErrorHandling(),
{
scheduled: false, // Don't start immediately
timezone: "UTC",
}
);
this.cronJob.start();
this.status = SchedulerStatus.RUNNING;
this.metrics.currentStatus = this.status;
this.emit("statusChange", this.status);
this.emit("started");
console.log(`[${this.name}] Scheduler started successfully`);
} catch (error) {
this.status = SchedulerStatus.ERROR;
this.metrics.currentStatus = this.status;
this.emit("statusChange", this.status);
this.emit("error", error);
throw error;
}
}
/**
* Stop the scheduler
*/
async stop(): Promise<void> {
if (this.status === SchedulerStatus.STOPPED) {
console.warn(`[${this.name}] Scheduler is already stopped`);
return;
}
try {
console.log(`[${this.name}] Stopping scheduler...`);
if (this.cronJob) {
this.cronJob.stop();
this.cronJob.destroy();
this.cronJob = undefined;
}
// Wait for current execution to finish if running
while (this.isRunning) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
this.status = SchedulerStatus.STOPPED;
this.metrics.currentStatus = this.status;
this.emit("statusChange", this.status);
this.emit("stopped");
console.log(`[${this.name}] Scheduler stopped successfully`);
} catch (error) {
this.status = SchedulerStatus.ERROR;
this.metrics.currentStatus = this.status;
this.emit("statusChange", this.status);
this.emit("error", error);
throw error;
}
}
/**
* Pause the scheduler
*/
pause(): void {
if (this.cronJob && this.status === SchedulerStatus.RUNNING) {
this.cronJob.stop();
this.status = SchedulerStatus.PAUSED;
this.metrics.currentStatus = this.status;
this.emit("statusChange", this.status);
this.emit("paused");
console.log(`[${this.name}] Scheduler paused`);
}
}
/**
* Resume the scheduler
*/
resume(): void {
if (this.cronJob && this.status === SchedulerStatus.PAUSED) {
this.cronJob.start();
this.status = SchedulerStatus.RUNNING;
this.metrics.currentStatus = this.status;
this.emit("statusChange", this.status);
this.emit("resumed");
console.log(`[${this.name}] Scheduler resumed`);
}
}
/**
* Get current scheduler status
*/
getStatus(): SchedulerStatus {
return this.status;
}
/**
* Get scheduler metrics
*/
getMetrics(): SchedulerMetrics {
return { ...this.metrics };
}
/**
* Get scheduler configuration
*/
getConfig(): SchedulerConfig {
return { ...this.config };
}
/**
* Update scheduler configuration
*/
updateConfig(newConfig: Partial<SchedulerConfig>): void {
const wasRunning = this.status === SchedulerStatus.RUNNING;
if (wasRunning) {
this.pause();
}
this.config = { ...this.config, ...newConfig };
if (wasRunning && newConfig.interval) {
// Recreate cron job with new interval
if (this.cronJob) {
this.cronJob.destroy();
}
this.cronJob = cron.schedule(
this.config.interval,
() => this.runWithErrorHandling(),
{
scheduled: false,
timezone: "UTC",
}
);
}
if (wasRunning) {
this.resume();
}
this.emit("configUpdated", this.config);
}
/**
* Manual trigger of the scheduler task
*/
async trigger(): Promise<void> {
if (this.isRunning) {
throw new Error(`[${this.name}] Task is already running`);
}
await this.runWithErrorHandling();
}
/**
* Get health status for load balancer/orchestrator
*/
getHealthStatus(): {
healthy: boolean;
status: SchedulerStatus;
lastSuccess: Date | null;
consecutiveFailures: number;
} {
const consecutiveFailures = this.calculateConsecutiveFailures();
const healthy =
this.status === SchedulerStatus.RUNNING &&
consecutiveFailures < this.config.maxRetries &&
(!this.metrics.lastErrorAt ||
!this.metrics.lastSuccessAt ||
this.metrics.lastSuccessAt > this.metrics.lastErrorAt);
return {
healthy,
status: this.status,
lastSuccess: this.metrics.lastSuccessAt,
consecutiveFailures,
};
}
/**
* Run the task with error handling and metrics collection
*/
private async runWithErrorHandling(): Promise<void> {
if (this.isRunning) {
console.warn(
`[${this.name}] Previous task still running, skipping this iteration`
);
return;
}
this.isRunning = true;
const startTime = Date.now();
try {
this.metrics.totalRuns++;
this.metrics.lastRunAt = new Date();
this.emit("taskStarted");
// Set timeout for task execution
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Task timeout")),
this.config.timeout
);
});
await Promise.race([this.executeTask(), timeoutPromise]);
const duration = Date.now() - startTime;
this.updateRunTimeMetrics(duration);
this.metrics.successfulRuns++;
this.metrics.lastSuccessAt = new Date();
this.emit("taskCompleted", { duration });
} catch (error) {
const duration = Date.now() - startTime;
this.metrics.failedRuns++;
this.metrics.lastErrorAt = new Date();
console.error(`[${this.name}] Task failed:`, error);
this.emit("taskFailed", { error, duration });
// Check if we should retry
const consecutiveFailures = this.calculateConsecutiveFailures();
if (consecutiveFailures >= this.config.maxRetries) {
this.status = SchedulerStatus.ERROR;
this.metrics.currentStatus = this.status;
this.emit("statusChange", this.status);
console.error(
`[${this.name}] Max retries exceeded, scheduler marked as ERROR`
);
}
} finally {
this.isRunning = false;
}
}
/**
* Update average run time metrics
*/
private updateRunTimeMetrics(duration: number): void {
if (this.metrics.averageRunTime === 0) {
this.metrics.averageRunTime = duration;
} else {
// Calculate running average
this.metrics.averageRunTime =
(this.metrics.averageRunTime + duration) / 2;
}
}
/**
* Calculate consecutive failures for health monitoring
*/
private calculateConsecutiveFailures(): number {
// This is a simplified version - in production you might want to track
// a rolling window of recent execution results
if (!this.metrics.lastSuccessAt || !this.metrics.lastErrorAt) {
return this.metrics.failedRuns;
}
return this.metrics.lastErrorAt > this.metrics.lastSuccessAt
? this.metrics.failedRuns - this.metrics.successfulRuns
: 0;
}
}

View File

@ -0,0 +1,317 @@
import { fetchAndParseCsv } from "../../csvFetcher";
import { prisma } from "../../prisma";
import {
BaseSchedulerService,
type SchedulerConfig,
} from "./BaseSchedulerService";
/**
* CSV Import specific configuration
*/
export interface CsvImportSchedulerConfig extends SchedulerConfig {
batchSize: number;
maxConcurrentImports: number;
skipDuplicateCheck: boolean;
}
/**
* CSV Import scheduler service
* Handles periodic CSV data import from companies
*/
export class CsvImportSchedulerService extends BaseSchedulerService {
private csvConfig: CsvImportSchedulerConfig;
constructor(config: Partial<CsvImportSchedulerConfig> = {}) {
const defaultConfig = {
interval: "*/10 * * * *", // Every 10 minutes
timeout: 300000, // 5 minutes timeout
batchSize: 10,
maxConcurrentImports: 5,
skipDuplicateCheck: false,
...config,
};
super("CSV Import Scheduler", defaultConfig);
this.csvConfig = defaultConfig;
}
/**
* Execute CSV import task
*/
protected async executeTask(): Promise<void> {
console.log(`[${this.name}] Starting CSV import batch processing...`);
let totalProcessed = 0;
let totalImported = 0;
let totalErrors = 0;
// Process companies in batches to avoid memory issues
let skip = 0;
let hasMore = true;
while (hasMore) {
const companies = await prisma.company.findMany({
where: {
status: "ACTIVE",
csvUrl: { not: null }, // Only companies with CSV URLs
},
take: this.csvConfig.batchSize,
skip: skip,
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
csvUrl: true,
csvUsername: true,
csvPassword: true,
},
});
if (companies.length === 0) {
hasMore = false;
break;
}
totalProcessed += companies.length;
// Process companies with controlled concurrency
const results = await this.processBatchWithConcurrency(companies);
results.forEach((result) => {
if (result.success) {
totalImported += result.importedCount || 0;
} else {
totalErrors++;
console.error(
`[${this.name}] Failed to process company ${result.companyId}:`,
result.error
);
}
});
skip += this.csvConfig.batchSize;
// Emit progress event
this.emit("progress", {
processed: totalProcessed,
imported: totalImported,
errors: totalErrors,
});
}
console.log(
`[${this.name}] Batch processing completed. ` +
`Processed: ${totalProcessed}, Imported: ${totalImported}, Errors: ${totalErrors}`
);
// Emit completion metrics
this.emit("batchCompleted", {
totalProcessed,
totalImported,
totalErrors,
});
}
/**
* Process a batch of companies with controlled concurrency
*/
private async processBatchWithConcurrency(
companies: Array<{
id: string;
name: string;
csvUrl: string | null;
csvUsername: string | null;
csvPassword: string | null;
}>
): Promise<
Array<{
companyId: string;
success: boolean;
importedCount?: number;
error?: Error;
}>
> {
const results: Array<{
companyId: string;
success: boolean;
importedCount?: number;
error?: Error;
}> = [];
// Process companies in chunks to control concurrency
const chunkSize = this.csvConfig.maxConcurrentImports;
for (let i = 0; i < companies.length; i += chunkSize) {
const chunk = companies.slice(i, i + chunkSize);
const chunkResults = await Promise.allSettled(
chunk.map((company) => this.processCompanyImport(company))
);
chunkResults.forEach((result, index) => {
const company = chunk[index];
if (result.status === "fulfilled") {
results.push({
companyId: company.id,
success: true,
importedCount: result.value,
});
} else {
results.push({
companyId: company.id,
success: false,
error: result.reason,
});
}
});
}
return results;
}
/**
* Process CSV import for a single company
*/
private async processCompanyImport(company: {
id: string;
name: string;
csvUrl: string | null;
csvUsername: string | null;
csvPassword: string | null;
}): Promise<number> {
if (!company.csvUrl) {
throw new Error(`Company ${company.name} has no CSV URL configured`);
}
console.log(
`[${this.name}] Processing CSV import for company: ${company.name}`
);
try {
// Fetch and parse CSV data
const rawSessionData = await fetchAndParseCsv(
company.csvUrl,
company.csvUsername || undefined,
company.csvPassword || undefined
);
let importedCount = 0;
// Create SessionImport records for new data
for (const rawSession of rawSessionData) {
try {
// Check for duplicates if not skipping
if (!this.csvConfig.skipDuplicateCheck) {
const existing = await prisma.sessionImport.findFirst({
where: {
companyId: company.id,
externalId: rawSession.externalId,
},
});
if (existing) {
console.log(
`[${this.name}] Skipping duplicate session: ${rawSession.externalId} for company: ${company.name}`
);
continue;
}
}
// Create new session import record
await prisma.sessionImport.create({
data: {
companyId: company.id,
externalId: rawSession.externalId,
csvData: rawSession.csvData,
status: "PENDING_PROCESSING",
metadata: {
importedAt: new Date().toISOString(),
csvUrl: company.csvUrl,
batchId: `batch_${Date.now()}`,
},
},
});
importedCount++;
} catch (sessionError) {
console.error(
`[${this.name}] Failed to import session ${rawSession.externalId} for company ${company.name}:`,
sessionError
);
// Continue with other sessions
}
}
console.log(
`[${this.name}] Successfully imported ${importedCount} sessions for company: ${company.name}`
);
return importedCount;
} catch (error) {
console.error(
`[${this.name}] Failed to process CSV import for company ${company.name}:`,
error
);
throw error;
}
}
/**
* Get CSV import specific metrics
*/
getCsvImportMetrics(): {
totalCompaniesProcessed: number;
totalSessionsImported: number;
averageImportTime: number;
errorRate: number;
} {
const baseMetrics = this.getMetrics();
// These would be enhanced with actual tracking in a production system
return {
totalCompaniesProcessed: baseMetrics.successfulRuns,
totalSessionsImported: 0, // Would track actual import counts
averageImportTime: baseMetrics.averageRunTime,
errorRate:
baseMetrics.totalRuns > 0
? baseMetrics.failedRuns / baseMetrics.totalRuns
: 0,
};
}
/**
* Trigger import for a specific company
*/
async triggerCompanyImport(companyId: string): Promise<number> {
const company = await prisma.company.findUnique({
where: { id: companyId },
select: {
id: true,
name: true,
csvUrl: true,
csvUsername: true,
csvPassword: true,
},
});
if (!company) {
throw new Error(`Company with ID ${companyId} not found`);
}
return this.processCompanyImport(company);
}
/**
* Update CSV-specific configuration
*/
updateCsvConfig(newConfig: Partial<CsvImportSchedulerConfig>): void {
this.csvConfig = { ...this.csvConfig, ...newConfig };
this.updateConfig(newConfig);
}
/**
* Get CSV-specific configuration
*/
getCsvConfig(): CsvImportSchedulerConfig {
return { ...this.csvConfig };
}
}

View File

@ -0,0 +1,422 @@
import { EventEmitter } from "node:events";
import {
type BaseSchedulerService,
SchedulerStatus,
} from "./BaseSchedulerService";
import { CsvImportSchedulerService } from "./CsvImportSchedulerService";
/**
* Scheduler manager configuration
*/
export interface SchedulerManagerConfig {
enabled: boolean;
autoRestart: boolean;
healthCheckInterval: number;
maxRestartAttempts: number;
restartDelay: number;
}
/**
* Scheduler registration interface
*/
export interface SchedulerRegistration {
id: string;
name: string;
service: BaseSchedulerService;
autoStart: boolean;
critical: boolean; // If true, manager will try to restart on failure
}
/**
* Manager health status
*/
export interface ManagerHealthStatus {
healthy: boolean;
totalSchedulers: number;
runningSchedulers: number;
errorSchedulers: number;
schedulerStatuses: Record<
string,
{
status: SchedulerStatus;
healthy: boolean;
lastSuccess: Date | null;
}
>;
}
/**
* Scheduler Manager
* Orchestrates multiple scheduler services for horizontal scaling
*/
export class SchedulerManager extends EventEmitter {
private schedulers = new Map<string, SchedulerRegistration>();
private config: SchedulerManagerConfig;
private healthCheckTimer?: NodeJS.Timeout;
private restartAttempts = new Map<string, number>();
constructor(config: Partial<SchedulerManagerConfig> = {}) {
super();
this.config = {
enabled: true,
autoRestart: true,
healthCheckInterval: 30000, // 30 seconds
maxRestartAttempts: 3,
restartDelay: 5000, // 5 seconds
...config,
};
}
/**
* Register a scheduler service
*/
registerScheduler(registration: SchedulerRegistration): void {
if (this.schedulers.has(registration.id)) {
throw new Error(
`Scheduler with ID ${registration.id} is already registered`
);
}
// Set up event listeners for the scheduler
this.setupSchedulerEventListeners(registration);
this.schedulers.set(registration.id, registration);
this.restartAttempts.set(registration.id, 0);
console.log(
`[Scheduler Manager] Registered scheduler: ${registration.name}`
);
this.emit("schedulerRegistered", registration);
}
/**
* Unregister a scheduler service
*/
async unregisterScheduler(schedulerId: string): Promise<void> {
const registration = this.schedulers.get(schedulerId);
if (!registration) {
throw new Error(`Scheduler with ID ${schedulerId} is not registered`);
}
// Stop the scheduler if running
if (registration.service.getStatus() === SchedulerStatus.RUNNING) {
await registration.service.stop();
}
// Remove event listeners
registration.service.removeAllListeners();
this.schedulers.delete(schedulerId);
this.restartAttempts.delete(schedulerId);
console.log(
`[Scheduler Manager] Unregistered scheduler: ${registration.name}`
);
this.emit("schedulerUnregistered", registration);
}
/**
* Start all registered schedulers
*/
async startAll(): Promise<void> {
if (!this.config.enabled) {
console.log("[Scheduler Manager] Disabled via configuration");
return;
}
console.log("[Scheduler Manager] Starting all schedulers...");
const startPromises = Array.from(this.schedulers.values())
.filter((reg) => reg.autoStart)
.map(async (registration) => {
try {
await registration.service.start();
console.log(`[Scheduler Manager] Started: ${registration.name}`);
} catch (error) {
console.error(
`[Scheduler Manager] Failed to start ${registration.name}:`,
error
);
this.emit("schedulerStartFailed", { registration, error });
}
});
await Promise.allSettled(startPromises);
// Start health monitoring
this.startHealthMonitoring();
console.log("[Scheduler Manager] All schedulers started");
this.emit("allSchedulersStarted");
}
/**
* Stop all registered schedulers
*/
async stopAll(): Promise<void> {
console.log("[Scheduler Manager] Stopping all schedulers...");
// Stop health monitoring
this.stopHealthMonitoring();
const stopPromises = Array.from(this.schedulers.values()).map(
async (registration) => {
try {
await registration.service.stop();
console.log(`[Scheduler Manager] Stopped: ${registration.name}`);
} catch (error) {
console.error(
`[Scheduler Manager] Failed to stop ${registration.name}:`,
error
);
}
}
);
await Promise.allSettled(stopPromises);
console.log("[Scheduler Manager] All schedulers stopped");
this.emit("allSchedulersStopped");
}
/**
* Start a specific scheduler
*/
async startScheduler(schedulerId: string): Promise<void> {
const registration = this.schedulers.get(schedulerId);
if (!registration) {
throw new Error(`Scheduler with ID ${schedulerId} is not registered`);
}
await registration.service.start();
this.emit("schedulerStarted", registration);
}
/**
* Stop a specific scheduler
*/
async stopScheduler(schedulerId: string): Promise<void> {
const registration = this.schedulers.get(schedulerId);
if (!registration) {
throw new Error(`Scheduler with ID ${schedulerId} is not registered`);
}
await registration.service.stop();
this.emit("schedulerStopped", registration);
}
/**
* Get health status of all schedulers
*/
getHealthStatus(): ManagerHealthStatus {
const schedulerStatuses: Record<
string,
{
status: SchedulerStatus;
healthy: boolean;
lastSuccess: Date | null;
}
> = {};
let runningCount = 0;
let errorCount = 0;
for (const [id, registration] of this.schedulers) {
const health = registration.service.getHealthStatus();
const status = registration.service.getStatus();
schedulerStatuses[id] = {
status,
healthy: health.healthy,
lastSuccess: health.lastSuccess,
};
if (status === SchedulerStatus.RUNNING) runningCount++;
if (status === SchedulerStatus.ERROR) errorCount++;
}
const totalSchedulers = this.schedulers.size;
const healthy = errorCount === 0 && runningCount > 0;
return {
healthy,
totalSchedulers,
runningSchedulers: runningCount,
errorSchedulers: errorCount,
schedulerStatuses,
};
}
/**
* Get all registered schedulers
*/
getSchedulers(): Array<{
id: string;
name: string;
status: SchedulerStatus;
metrics: any;
}> {
return Array.from(this.schedulers.entries()).map(([id, registration]) => ({
id,
name: registration.name,
status: registration.service.getStatus(),
metrics: registration.service.getMetrics(),
}));
}
/**
* Get a specific scheduler
*/
getScheduler(schedulerId: string): BaseSchedulerService | null {
const registration = this.schedulers.get(schedulerId);
return registration ? registration.service : null;
}
/**
* Trigger manual execution of a specific scheduler
*/
async triggerScheduler(schedulerId: string): Promise<void> {
const registration = this.schedulers.get(schedulerId);
if (!registration) {
throw new Error(`Scheduler with ID ${schedulerId} is not registered`);
}
await registration.service.trigger();
this.emit("schedulerTriggered", registration);
}
/**
* Setup event listeners for a scheduler
*/
private setupSchedulerEventListeners(
registration: SchedulerRegistration
): void {
const { service } = registration;
service.on("statusChange", (status: SchedulerStatus) => {
this.emit("schedulerStatusChanged", { registration, status });
// Handle automatic restart for critical schedulers
if (
status === SchedulerStatus.ERROR &&
registration.critical &&
this.config.autoRestart
) {
this.handleSchedulerFailure(registration);
}
});
service.on("taskCompleted", (data) => {
this.emit("schedulerTaskCompleted", { registration, data });
// Reset restart attempts on successful completion
this.restartAttempts.set(registration.id, 0);
});
service.on("taskFailed", (data) => {
this.emit("schedulerTaskFailed", { registration, data });
});
service.on("error", (error) => {
this.emit("schedulerError", { registration, error });
});
}
/**
* Handle scheduler failure with automatic restart
*/
private async handleSchedulerFailure(
registration: SchedulerRegistration
): Promise<void> {
const attempts = this.restartAttempts.get(registration.id) || 0;
if (attempts >= this.config.maxRestartAttempts) {
console.error(
`[Scheduler Manager] Max restart attempts exceeded for ${registration.name}`
);
this.emit("schedulerRestartFailed", registration);
return;
}
console.log(
`[Scheduler Manager] Attempting to restart ${registration.name} (attempt ${attempts + 1})`
);
// Wait before restart
await new Promise((resolve) =>
setTimeout(resolve, this.config.restartDelay)
);
try {
await registration.service.stop();
await registration.service.start();
console.log(
`[Scheduler Manager] Successfully restarted ${registration.name}`
);
this.emit("schedulerRestarted", registration);
} catch (error) {
console.error(
`[Scheduler Manager] Failed to restart ${registration.name}:`,
error
);
this.restartAttempts.set(registration.id, attempts + 1);
this.emit("schedulerRestartError", { registration, error });
}
}
/**
* Start health monitoring
*/
private startHealthMonitoring(): void {
if (this.healthCheckTimer) return;
this.healthCheckTimer = setInterval(() => {
const health = this.getHealthStatus();
this.emit("healthCheck", health);
if (!health.healthy) {
console.warn("[Scheduler Manager] Health check failed:", health);
}
}, this.config.healthCheckInterval);
}
/**
* Stop health monitoring
*/
private stopHealthMonitoring(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = undefined;
}
}
/**
* Create and register default schedulers
*/
static createDefaultSchedulers(): SchedulerManager {
const manager = new SchedulerManager();
// Register CSV Import Scheduler
manager.registerScheduler({
id: "csv-import",
name: "CSV Import Scheduler",
service: new CsvImportSchedulerService({
interval: "*/10 * * * *", // Every 10 minutes
}),
autoStart: true,
critical: true,
});
// Additional schedulers would be registered here
// manager.registerScheduler({
// id: "processing",
// name: "Session Processing Scheduler",
// service: new SessionProcessingSchedulerService(),
// autoStart: true,
// critical: true,
// });
return manager;
}
}

View File

@ -0,0 +1,274 @@
import { getSchedulerConfig } from "../../env";
import { CsvImportSchedulerService } from "./CsvImportSchedulerService";
import { SchedulerManager } from "./SchedulerManager";
/**
* Server-side scheduler integration
* Manages all schedulers for the application server
*/
export class ServerSchedulerIntegration {
private static instance: ServerSchedulerIntegration;
private manager: SchedulerManager;
private isInitialized = false;
private constructor() {
this.manager = new SchedulerManager({
enabled: true,
autoRestart: true,
healthCheckInterval: 30000,
maxRestartAttempts: 3,
restartDelay: 5000,
});
this.setupManagerEventListeners();
}
/**
* Get singleton instance
*/
static getInstance(): ServerSchedulerIntegration {
if (!ServerSchedulerIntegration.instance) {
ServerSchedulerIntegration.instance = new ServerSchedulerIntegration();
}
return ServerSchedulerIntegration.instance;
}
/**
* Initialize schedulers based on environment configuration
*/
async initialize(): Promise<void> {
if (this.isInitialized) {
console.warn("[Server Scheduler Integration] Already initialized");
return;
}
const config = getSchedulerConfig();
if (!config.enabled) {
console.log(
"[Server Scheduler Integration] Schedulers disabled via configuration"
);
return;
}
try {
console.log("[Server Scheduler Integration] Initializing schedulers...");
// Register CSV Import Scheduler
this.manager.registerScheduler({
id: "csv-import",
name: "CSV Import Scheduler",
service: new CsvImportSchedulerService({
enabled: config.csvImport.enabled,
interval: config.csvImport.interval,
timeout: 300000, // 5 minutes
batchSize: 10,
maxConcurrentImports: 5,
}),
autoStart: true,
critical: true,
});
// TODO: Add other schedulers when they are converted
// this.manager.registerScheduler({
// id: "import-processing",
// name: "Import Processing Scheduler",
// service: new ImportProcessingSchedulerService({
// enabled: config.importProcessing.enabled,
// interval: config.importProcessing.interval,
// }),
// autoStart: true,
// critical: true,
// });
// this.manager.registerScheduler({
// id: "session-processing",
// name: "Session Processing Scheduler",
// service: new SessionProcessingSchedulerService({
// enabled: config.sessionProcessing.enabled,
// interval: config.sessionProcessing.interval,
// }),
// autoStart: true,
// critical: true,
// });
// this.manager.registerScheduler({
// id: "batch-processing",
// name: "Batch Processing Scheduler",
// service: new BatchProcessingSchedulerService({
// enabled: config.batchProcessing.enabled,
// interval: config.batchProcessing.interval,
// }),
// autoStart: true,
// critical: true,
// });
// Start all registered schedulers
await this.manager.startAll();
this.isInitialized = true;
console.log(
"[Server Scheduler Integration] All schedulers initialized successfully"
);
} catch (error) {
console.error(
"[Server Scheduler Integration] Failed to initialize schedulers:",
error
);
throw error;
}
}
/**
* Shutdown all schedulers
*/
async shutdown(): Promise<void> {
if (!this.isInitialized) {
console.warn("[Server Scheduler Integration] Not initialized");
return;
}
try {
console.log("[Server Scheduler Integration] Shutting down schedulers...");
await this.manager.stopAll();
this.isInitialized = false;
console.log("[Server Scheduler Integration] All schedulers stopped");
} catch (error) {
console.error(
"[Server Scheduler Integration] Error during shutdown:",
error
);
throw error;
}
}
/**
* Get scheduler manager for external access
*/
getManager(): SchedulerManager {
return this.manager;
}
/**
* Get health status of all schedulers
*/
getHealthStatus() {
return this.manager.getHealthStatus();
}
/**
* Get list of all schedulers with their status
*/
getSchedulersList() {
return this.manager.getSchedulers();
}
/**
* Trigger manual execution of a specific scheduler
*/
async triggerScheduler(schedulerId: string): Promise<void> {
return this.manager.triggerScheduler(schedulerId);
}
/**
* Start a specific scheduler
*/
async startScheduler(schedulerId: string): Promise<void> {
return this.manager.startScheduler(schedulerId);
}
/**
* Stop a specific scheduler
*/
async stopScheduler(schedulerId: string): Promise<void> {
return this.manager.stopScheduler(schedulerId);
}
/**
* Setup event listeners for the manager
*/
private setupManagerEventListeners(): void {
this.manager.on("schedulerStatusChanged", ({ registration, status }) => {
console.log(
`[Server Scheduler Integration] ${registration.name} status changed to: ${status}`
);
});
this.manager.on("schedulerTaskCompleted", ({ registration, data }) => {
console.log(
`[Server Scheduler Integration] ${registration.name} task completed in ${data.duration}ms`
);
});
this.manager.on("schedulerTaskFailed", ({ registration, data }) => {
console.error(
`[Server Scheduler Integration] ${registration.name} task failed:`,
data.error
);
});
this.manager.on("schedulerRestarted", (registration) => {
console.log(
`[Server Scheduler Integration] Successfully restarted: ${registration.name}`
);
});
this.manager.on("schedulerRestartFailed", (registration) => {
console.error(
`[Server Scheduler Integration] Failed to restart: ${registration.name}`
);
});
this.manager.on("healthCheck", (health) => {
if (!health.healthy) {
console.warn("[Server Scheduler Integration] Health check failed:", {
totalSchedulers: health.totalSchedulers,
runningSchedulers: health.runningSchedulers,
errorSchedulers: health.errorSchedulers,
});
}
});
}
/**
* Handle graceful shutdown
*/
async handleGracefulShutdown(): Promise<void> {
console.log(
"[Server Scheduler Integration] Received shutdown signal, stopping schedulers..."
);
try {
await this.shutdown();
console.log("[Server Scheduler Integration] Graceful shutdown completed");
} catch (error) {
console.error(
"[Server Scheduler Integration] Error during graceful shutdown:",
error
);
process.exit(1);
}
}
}
/**
* Convenience function to get the scheduler integration instance
*/
export const getSchedulerIntegration = () =>
ServerSchedulerIntegration.getInstance();
/**
* Initialize schedulers for server startup
*/
export const initializeSchedulers = async (): Promise<void> => {
const integration = getSchedulerIntegration();
await integration.initialize();
};
/**
* Shutdown schedulers for server shutdown
*/
export const shutdownSchedulers = async (): Promise<void> => {
const integration = getSchedulerIntegration();
await integration.shutdown();
};

View File

@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* Standalone Scheduler Runner
* Runs individual schedulers as separate processes for horizontal scaling
*
* Usage:
* npx tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=csv-import
* npx tsx lib/services/schedulers/StandaloneSchedulerRunner.ts --scheduler=session-processing
*/
import { Command } from "commander";
import { validateEnv } from "../../env";
import {
type BaseSchedulerService,
SchedulerStatus,
} from "./BaseSchedulerService";
import { CsvImportSchedulerService } from "./CsvImportSchedulerService";
interface SchedulerFactory {
[key: string]: () => BaseSchedulerService;
}
/**
* Available schedulers for standalone execution
*/
const AVAILABLE_SCHEDULERS: SchedulerFactory = {
"csv-import": () =>
new CsvImportSchedulerService({
interval: process.env.CSV_IMPORT_INTERVAL || "*/10 * * * *",
timeout: Number.parseInt(process.env.CSV_IMPORT_TIMEOUT || "300000"),
batchSize: Number.parseInt(process.env.CSV_IMPORT_BATCH_SIZE || "10"),
maxConcurrentImports: Number.parseInt(
process.env.CSV_IMPORT_MAX_CONCURRENT || "5"
),
}),
// Additional schedulers would be added here:
// "import-processing": () => new ImportProcessingSchedulerService({
// interval: process.env.IMPORT_PROCESSING_INTERVAL || "*/2 * * * *",
// }),
// "session-processing": () => new SessionProcessingSchedulerService({
// interval: process.env.SESSION_PROCESSING_INTERVAL || "*/5 * * * *",
// }),
// "batch-processing": () => new BatchProcessingSchedulerService({
// interval: process.env.BATCH_PROCESSING_INTERVAL || "*/5 * * * *",
// }),
};
/**
* Standalone Scheduler Runner Class
*/
class StandaloneSchedulerRunner {
private scheduler?: BaseSchedulerService;
private isShuttingDown = false;
constructor(private schedulerName: string) {}
/**
* Run the specified scheduler
*/
async run(): Promise<void> {
try {
// Validate environment
const envValidation = validateEnv();
if (!envValidation.valid) {
console.error(
"[Standalone Scheduler] Environment validation errors:",
envValidation.errors
);
process.exit(1);
}
// Create scheduler instance
const factory = AVAILABLE_SCHEDULERS[this.schedulerName];
if (!factory) {
console.error(
`[Standalone Scheduler] Unknown scheduler: ${this.schedulerName}`
);
console.error(
`Available schedulers: ${Object.keys(AVAILABLE_SCHEDULERS).join(", ")}`
);
process.exit(1);
}
this.scheduler = factory();
// Setup event listeners
this.setupEventListeners();
// Setup graceful shutdown
this.setupGracefulShutdown();
console.log(`[Standalone Scheduler] Starting ${this.schedulerName}...`);
// Start the scheduler
await this.scheduler.start();
console.log(`[Standalone Scheduler] ${this.schedulerName} is running`);
// Keep the process alive
this.keepAlive();
} catch (error) {
console.error(
`[Standalone Scheduler] Failed to start ${this.schedulerName}:`,
error
);
process.exit(1);
}
}
/**
* Setup event listeners for the scheduler
*/
private setupEventListeners(): void {
if (!this.scheduler) return;
this.scheduler.on("statusChange", (status: SchedulerStatus) => {
console.log(`[Standalone Scheduler] Status changed to: ${status}`);
if (status === SchedulerStatus.ERROR && !this.isShuttingDown) {
console.error(
"[Standalone Scheduler] Scheduler entered ERROR state, exiting..."
);
process.exit(1);
}
});
this.scheduler.on("taskCompleted", (data) => {
console.log(
`[Standalone Scheduler] Task completed in ${data.duration}ms`
);
});
this.scheduler.on("taskFailed", (data) => {
console.error(
"[Standalone Scheduler] Task failed:",
data.error?.message || data.error
);
});
this.scheduler.on("started", () => {
console.log(
`[Standalone Scheduler] ${this.schedulerName} started successfully`
);
});
this.scheduler.on("stopped", () => {
console.log(`[Standalone Scheduler] ${this.schedulerName} stopped`);
});
// Setup health reporting
setInterval(() => {
if (this.scheduler && !this.isShuttingDown) {
const health = this.scheduler.getHealthStatus();
const metrics = this.scheduler.getMetrics();
console.log(
`[Standalone Scheduler] Health: ${health.healthy ? "OK" : "UNHEALTHY"}, ` +
`Runs: ${metrics.totalRuns}, Success: ${metrics.successfulRuns}, ` +
`Failed: ${metrics.failedRuns}, Avg Time: ${metrics.averageRunTime}ms`
);
}
}, 60000); // Every minute
}
/**
* Setup graceful shutdown handlers
*/
private setupGracefulShutdown(): void {
const gracefulShutdown = async (signal: string) => {
if (this.isShuttingDown) return;
console.log(
`[Standalone Scheduler] Received ${signal}, shutting down gracefully...`
);
this.isShuttingDown = true;
try {
if (this.scheduler) {
await this.scheduler.stop();
}
console.log("[Standalone Scheduler] Graceful shutdown completed");
process.exit(0);
} catch (error) {
console.error("[Standalone Scheduler] Error during shutdown:", error);
process.exit(1);
}
};
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("uncaughtException", (error) => {
console.error("[Standalone Scheduler] Uncaught exception:", error);
gracefulShutdown("uncaughtException");
});
process.on("unhandledRejection", (reason, promise) => {
console.error(
"[Standalone Scheduler] Unhandled rejection at:",
promise,
"reason:",
reason
);
gracefulShutdown("unhandledRejection");
});
}
/**
* Keep the process alive
*/
private keepAlive(): void {
// Setup periodic health checks
setInterval(() => {
if (!this.isShuttingDown && this.scheduler) {
const status = this.scheduler.getStatus();
if (status === SchedulerStatus.ERROR) {
console.error(
"[Standalone Scheduler] Scheduler is in ERROR state, exiting..."
);
process.exit(1);
}
}
}, 30000); // Every 30 seconds
}
}
/**
* Main execution function
*/
async function main(): Promise<void> {
const program = new Command();
program
.name("standalone-scheduler")
.description("Run individual schedulers as standalone processes")
.version("1.0.0")
.requiredOption("-s, --scheduler <name>", "Scheduler name to run")
.option("-l, --list", "List available schedulers")
.parse();
const options = program.opts();
if (options.list) {
console.log("Available schedulers:");
Object.keys(AVAILABLE_SCHEDULERS).forEach((name) => {
console.log(` - ${name}`);
});
return;
}
if (!options.scheduler) {
console.error(
"Scheduler name is required. Use --list to see available schedulers."
);
process.exit(1);
}
const runner = new StandaloneSchedulerRunner(options.scheduler);
await runner.run();
}
// Run if called directly
if (require.main === module) {
main().catch((error) => {
console.error("[Standalone Scheduler] Fatal error:", error);
process.exit(1);
});
}
export { StandaloneSchedulerRunner, AVAILABLE_SCHEDULERS };