|
@@ -0,0 +1,414 @@
|
|
|
|
|
+import { logger } from '../utils/logger';
|
|
|
|
|
+import { ShopDatabase } from '../database/Database';
|
|
|
|
|
+import { QdrantCleanupService } from './QdrantCleanupService';
|
|
|
|
|
+import { QdrantEmbeddingWorkflow } from './QdrantEmbeddingWorkflow';
|
|
|
|
|
+import { WebhookManager } from '../webhooks/WebhookManager';
|
|
|
|
|
+import type { AppConfig } from '../config';
|
|
|
|
|
+
|
|
|
|
|
+export interface SchedulerConfig {
|
|
|
|
|
+ cleanupIntervalMs: number;
|
|
|
|
|
+ healthCheckIntervalMs: number;
|
|
|
|
|
+ deletionQueueIntervalMs: number;
|
|
|
|
|
+ logCleanupIntervalMs: number;
|
|
|
|
|
+ enabledTasks: {
|
|
|
|
|
+ cleanup: boolean;
|
|
|
|
|
+ healthCheck: boolean;
|
|
|
|
|
+ deletionQueue: boolean;
|
|
|
|
|
+ logCleanup: boolean;
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class SchedulerService {
|
|
|
|
|
+ private intervals: NodeJS.Timeout[] = [];
|
|
|
|
|
+ private isRunning = false;
|
|
|
|
|
+ private cleanupService: QdrantCleanupService;
|
|
|
|
|
+ private embeddingWorkflow: QdrantEmbeddingWorkflow;
|
|
|
|
|
+ private webhookManager: WebhookManager;
|
|
|
|
|
+
|
|
|
|
|
+ private config: SchedulerConfig = {
|
|
|
|
|
+ cleanupIntervalMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
|
|
|
+ healthCheckIntervalMs: 5 * 60 * 1000, // 5 minutes
|
|
|
|
|
+ deletionQueueIntervalMs: 60 * 60 * 1000, // 1 hour
|
|
|
|
|
+ logCleanupIntervalMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
|
|
|
+ enabledTasks: {
|
|
|
|
|
+ cleanup: true,
|
|
|
|
|
+ healthCheck: true,
|
|
|
|
|
+ deletionQueue: true,
|
|
|
|
|
+ logCleanup: true
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ constructor(
|
|
|
|
|
+ private appConfig: AppConfig,
|
|
|
|
|
+ private database: ShopDatabase
|
|
|
|
|
+ ) {
|
|
|
|
|
+ this.cleanupService = new QdrantCleanupService(appConfig, database);
|
|
|
|
|
+ this.embeddingWorkflow = new QdrantEmbeddingWorkflow(appConfig, database);
|
|
|
|
|
+ this.webhookManager = new WebhookManager(database);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Start the scheduler with all enabled tasks
|
|
|
|
|
+ */
|
|
|
|
|
+ start(customConfig?: Partial<SchedulerConfig>): void {
|
|
|
|
|
+ if (this.isRunning) {
|
|
|
|
|
+ logger.warn('Scheduler is already running');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Merge custom config
|
|
|
|
|
+ if (customConfig) {
|
|
|
|
|
+ this.config = {
|
|
|
|
|
+ ...this.config,
|
|
|
|
|
+ ...customConfig,
|
|
|
|
|
+ enabledTasks: {
|
|
|
|
|
+ ...this.config.enabledTasks,
|
|
|
|
|
+ ...(customConfig.enabledTasks || {})
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info('Starting scheduler service with config:', this.config);
|
|
|
|
|
+
|
|
|
|
|
+ // Schedule cleanup tasks
|
|
|
|
|
+ if (this.config.enabledTasks.cleanup) {
|
|
|
|
|
+ const cleanupInterval = setInterval(() => {
|
|
|
|
|
+ this.runCleanupTask().catch(error => {
|
|
|
|
|
+ logger.error('Cleanup task failed:', error);
|
|
|
|
|
+ });
|
|
|
|
|
+ }, this.config.cleanupIntervalMs);
|
|
|
|
|
+ this.intervals.push(cleanupInterval);
|
|
|
|
|
+ logger.info(`Scheduled cleanup task every ${this.config.cleanupIntervalMs / 1000}s`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Schedule health checks
|
|
|
|
|
+ if (this.config.enabledTasks.healthCheck) {
|
|
|
|
|
+ const healthInterval = setInterval(() => {
|
|
|
|
|
+ this.runHealthCheckTask().catch(error => {
|
|
|
|
|
+ logger.error('Health check task failed:', error);
|
|
|
|
|
+ });
|
|
|
|
|
+ }, this.config.healthCheckIntervalMs);
|
|
|
|
|
+ this.intervals.push(healthInterval);
|
|
|
|
|
+ logger.info(`Scheduled health check every ${this.config.healthCheckIntervalMs / 1000}s`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Schedule deletion queue processing
|
|
|
|
|
+ if (this.config.enabledTasks.deletionQueue) {
|
|
|
|
|
+ const deletionInterval = setInterval(() => {
|
|
|
|
|
+ this.processDeletionQueue().catch(error => {
|
|
|
|
|
+ logger.error('Deletion queue processing failed:', error);
|
|
|
|
|
+ });
|
|
|
|
|
+ }, this.config.deletionQueueIntervalMs);
|
|
|
|
|
+ this.intervals.push(deletionInterval);
|
|
|
|
|
+ logger.info(`Scheduled deletion queue processing every ${this.config.deletionQueueIntervalMs / 1000}s`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Schedule log cleanup
|
|
|
|
|
+ if (this.config.enabledTasks.logCleanup) {
|
|
|
|
|
+ const logCleanupInterval = setInterval(() => {
|
|
|
|
|
+ this.runLogCleanup().catch(error => {
|
|
|
|
|
+ logger.error('Log cleanup failed:', error);
|
|
|
|
|
+ });
|
|
|
|
|
+ }, this.config.logCleanupIntervalMs);
|
|
|
|
|
+ this.intervals.push(logCleanupInterval);
|
|
|
|
|
+ logger.info(`Scheduled log cleanup every ${this.config.logCleanupIntervalMs / 1000}s`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.isRunning = true;
|
|
|
|
|
+
|
|
|
|
|
+ // Run initial health check
|
|
|
|
|
+ if (this.config.enabledTasks.healthCheck) {
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ this.runHealthCheckTask().catch(error => {
|
|
|
|
|
+ logger.error('Initial health check failed:', error);
|
|
|
|
|
+ });
|
|
|
|
|
+ }, 10000); // Wait 10 seconds after startup
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Stop the scheduler
|
|
|
|
|
+ */
|
|
|
|
|
+ stop(): void {
|
|
|
|
|
+ if (!this.isRunning) {
|
|
|
|
|
+ logger.warn('Scheduler is not running');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info('Stopping scheduler service');
|
|
|
|
|
+
|
|
|
|
|
+ // Clear all intervals
|
|
|
|
|
+ this.intervals.forEach(interval => clearInterval(interval));
|
|
|
|
|
+ this.intervals = [];
|
|
|
|
|
+ this.isRunning = false;
|
|
|
|
|
+
|
|
|
|
|
+ logger.info('Scheduler service stopped');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get scheduler status
|
|
|
|
|
+ */
|
|
|
|
|
+ getStatus(): {
|
|
|
|
|
+ running: boolean;
|
|
|
|
|
+ activeIntervals: number;
|
|
|
|
|
+ config: SchedulerConfig;
|
|
|
|
|
+ lastHealthCheck?: Date;
|
|
|
|
|
+ lastCleanup?: Date;
|
|
|
|
|
+ } {
|
|
|
|
|
+ return {
|
|
|
|
|
+ running: this.isRunning,
|
|
|
|
|
+ activeIntervals: this.intervals.length,
|
|
|
|
|
+ config: this.config,
|
|
|
|
|
+ lastHealthCheck: this.getLastTaskTime('health_check'),
|
|
|
|
|
+ lastCleanup: this.getLastTaskTime('cleanup')
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Run cleanup task manually
|
|
|
|
|
+ */
|
|
|
|
|
+ async runCleanupTask(): Promise<void> {
|
|
|
|
|
+ logger.info('Starting scheduled cleanup task');
|
|
|
|
|
+ const startTime = Date.now();
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Clean up old deletion queue entries
|
|
|
|
|
+ this.database.cleanupDeletionQueue();
|
|
|
|
|
+ logger.debug('Cleaned up deletion queue');
|
|
|
|
|
+
|
|
|
|
|
+ // Clean up old error logs (keep last 30 days)
|
|
|
|
|
+ const thirtyDaysAgo = new Date();
|
|
|
|
|
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
|
|
|
+ const errorsCleaned = this.database.cleanupQdrantErrors(thirtyDaysAgo.toISOString());
|
|
|
|
|
+ logger.debug(`Cleaned up ${errorsCleaned} old error records`);
|
|
|
|
|
+
|
|
|
|
|
+ // Update last cleanup time
|
|
|
|
|
+ this.updateLastTaskTime('cleanup');
|
|
|
|
|
+
|
|
|
|
|
+ const duration = Date.now() - startTime;
|
|
|
|
|
+ logger.info(`Cleanup task completed in ${duration}ms`);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Cleanup task failed:', error);
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Run health check task
|
|
|
|
|
+ */
|
|
|
|
|
+ async runHealthCheckTask(): Promise<void> {
|
|
|
|
|
+ logger.debug('Starting health check task');
|
|
|
|
|
+ const startTime = Date.now();
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Check embedding workflow health
|
|
|
|
|
+ const health = await this.embeddingWorkflow.healthCheck();
|
|
|
|
|
+
|
|
|
|
|
+ if (!health.healthy) {
|
|
|
|
|
+ logger.warn('Health check failed:', health.errors);
|
|
|
|
|
+
|
|
|
|
|
+ // Notify about health check failures for enabled shops
|
|
|
|
|
+ const enabledShops = this.database.getQdrantEnabledShops();
|
|
|
|
|
+ for (const shop of enabledShops) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.webhookManager.notifyMcpHealthCheckFailed(shop.id, {
|
|
|
|
|
+ failed_services: Object.entries(health.services)
|
|
|
|
|
+ .filter(([_, status]) => !status)
|
|
|
|
|
+ .map(([service]) => service),
|
|
|
|
|
+ error_details: health.errors.reduce((acc, error, index) => {
|
|
|
|
|
+ acc[`error_${index}`] = error;
|
|
|
|
|
+ return acc;
|
|
|
|
|
+ }, {} as { [key: string]: string }),
|
|
|
|
|
+ consecutive_failures: this.getConsecutiveFailures()
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (webhookError) {
|
|
|
|
|
+ logger.error(`Failed to send health check webhook for shop ${shop.id}:`, webhookError);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.incrementConsecutiveFailures();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.resetConsecutiveFailures();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Update last health check time
|
|
|
|
|
+ this.updateLastTaskTime('health_check');
|
|
|
|
|
+
|
|
|
|
|
+ const duration = Date.now() - startTime;
|
|
|
|
|
+ logger.debug(`Health check completed in ${duration}ms`, health);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Health check task failed:', error);
|
|
|
|
|
+ this.incrementConsecutiveFailures();
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Process Qdrant deletion queue
|
|
|
|
|
+ */
|
|
|
|
|
+ async processDeletionQueue(): Promise<void> {
|
|
|
|
|
+ logger.info('Processing Qdrant deletion queue');
|
|
|
|
|
+ const startTime = Date.now();
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const queuedDeletions = this.database.getShopsForQdrantDeletion();
|
|
|
|
|
+
|
|
|
|
|
+ if (queuedDeletions.length === 0) {
|
|
|
|
|
+ logger.debug('No shops queued for Qdrant deletion');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`Processing ${queuedDeletions.length} queued deletions`);
|
|
|
|
|
+
|
|
|
|
|
+ for (const deletion of queuedDeletions) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.cleanupService.cleanupShopCollections(
|
|
|
|
|
+ deletion.custom_id,
|
|
|
|
|
+ deletion.shop_id
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Mark as completed
|
|
|
|
|
+ this.database.updateQdrantDeletionStatus(deletion.id, 'completed');
|
|
|
|
|
+
|
|
|
|
|
+ // Send webhook notification
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.webhookManager.notifyQdrantShopDeleted(deletion.shop_id, {
|
|
|
|
|
+ custom_id: deletion.custom_id,
|
|
|
|
|
+ collections_deleted: [], // Would be populated by cleanup service
|
|
|
|
|
+ total_vectors_deleted: 0, // Would be populated by cleanup service
|
|
|
|
|
+ deletion_scheduled_at: deletion.scheduled_deletion_at
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (webhookError) {
|
|
|
|
|
+ logger.error(`Failed to send shop deleted webhook for ${deletion.shop_id}:`, webhookError);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`Successfully processed Qdrant deletion for shop ${deletion.shop_id}`);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`Failed to process deletion for shop ${deletion.shop_id}:`, error);
|
|
|
|
|
+ this.database.updateQdrantDeletionStatus(
|
|
|
|
|
+ deletion.id,
|
|
|
|
|
+ 'failed',
|
|
|
|
|
+ error instanceof Error ? error.message : 'Unknown error'
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const duration = Date.now() - startTime;
|
|
|
|
|
+ logger.info(`Deletion queue processing completed in ${duration}ms`);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Deletion queue processing failed:', error);
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Run log cleanup task
|
|
|
|
|
+ */
|
|
|
|
|
+ async runLogCleanup(): Promise<void> {
|
|
|
|
|
+ logger.info('Starting log cleanup task');
|
|
|
|
|
+ const startTime = Date.now();
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Clean up old MCP logs (keep last 90 days)
|
|
|
|
|
+ const ninetyDaysAgo = new Date();
|
|
|
|
|
+ ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
|
|
|
|
+
|
|
|
|
|
+ const mcpLogsDeleted = this.database.cleanupMcpLogs(ninetyDaysAgo.toISOString());
|
|
|
|
|
+ logger.info(`Cleaned up ${mcpLogsDeleted} old MCP logs`);
|
|
|
|
|
+
|
|
|
|
|
+ // Clean up old webhook logs (if you have such a table)
|
|
|
|
|
+ // const webhookLogsDeleted = this.database.cleanupWebhookLogs(ninetyDaysAgo.toISOString());
|
|
|
|
|
+ // logger.info(`Cleaned up ${webhookLogsDeleted} old webhook logs`);
|
|
|
|
|
+
|
|
|
|
|
+ const duration = Date.now() - startTime;
|
|
|
|
|
+ logger.info(`Log cleanup completed in ${duration}ms`);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Log cleanup failed:', error);
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Helper methods for tracking task times and failures
|
|
|
|
|
+ private updateLastTaskTime(taskType: string): void {
|
|
|
|
|
+ // This would typically be stored in database or memory cache
|
|
|
|
|
+ // For now, just log it
|
|
|
|
|
+ logger.debug(`Updated last ${taskType} time to ${new Date().toISOString()}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private getLastTaskTime(taskType: string): Date | undefined {
|
|
|
|
|
+ // This would typically retrieve from database or memory cache
|
|
|
|
|
+ // For now, return undefined
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private consecutiveFailures = 0;
|
|
|
|
|
+
|
|
|
|
|
+ private incrementConsecutiveFailures(): void {
|
|
|
|
|
+ this.consecutiveFailures++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private resetConsecutiveFailures(): void {
|
|
|
|
|
+ this.consecutiveFailures = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private getConsecutiveFailures(): number {
|
|
|
|
|
+ return this.consecutiveFailures;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Manually trigger a specific task
|
|
|
|
|
+ */
|
|
|
|
|
+ async runTask(taskType: 'cleanup' | 'healthCheck' | 'deletionQueue' | 'logCleanup'): Promise<void> {
|
|
|
|
|
+ logger.info(`Manually triggering ${taskType} task`);
|
|
|
|
|
+
|
|
|
|
|
+ switch (taskType) {
|
|
|
|
|
+ case 'cleanup':
|
|
|
|
|
+ await this.runCleanupTask();
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'healthCheck':
|
|
|
|
|
+ await this.runHealthCheckTask();
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'deletionQueue':
|
|
|
|
|
+ await this.processDeletionQueue();
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'logCleanup':
|
|
|
|
|
+ await this.runLogCleanup();
|
|
|
|
|
+ break;
|
|
|
|
|
+ default:
|
|
|
|
|
+ throw new Error(`Unknown task type: ${taskType}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Update scheduler configuration at runtime
|
|
|
|
|
+ */
|
|
|
|
|
+ updateConfig(newConfig: Partial<SchedulerConfig>): void {
|
|
|
|
|
+ const oldConfig = { ...this.config };
|
|
|
|
|
+
|
|
|
|
|
+ this.config = {
|
|
|
|
|
+ ...this.config,
|
|
|
|
|
+ ...newConfig,
|
|
|
|
|
+ enabledTasks: {
|
|
|
|
|
+ ...this.config.enabledTasks,
|
|
|
|
|
+ ...(newConfig.enabledTasks || {})
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ logger.info('Scheduler configuration updated', {
|
|
|
|
|
+ old: oldConfig,
|
|
|
|
|
+ new: this.config
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Restart if running and intervals changed
|
|
|
|
|
+ if (this.isRunning) {
|
|
|
|
|
+ logger.info('Restarting scheduler with new configuration');
|
|
|
|
|
+ this.stop();
|
|
|
|
|
+ this.start();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|