import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { RETRY_DELAY_MS } from './nextcloud-alert-rules'; import { NextcloudReleaseService } from './nextcloud-release.service'; import { CHECK_CONCURRENCY, NextcloudStatusService, runWithConcurrency, } from './nextcloud-status.service'; /** * CronJob constructor — resolved at runtime via require() because `cron` is * a transitive dependency of @nestjs/schedule (not a direct api dep under * pnpm strict isolation, so `import { CronJob } from 'cron'` fails * type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares * it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution * workaround verbatim. */ // eslint-disable-next-line @typescript-eslint/no-require-imports const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } = // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access require('cron').CronJob as new ( cronTime: string, onTick: () => void, ) => { start(): void }; /** Name des Auftrags in der Registry. */ export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll'; /** Jede volle Stunde (L-08). */ export const NEXTCLOUD_CRON = '0 * * * *'; /** Name des Wiederholungsauftrags (quick-261002-kxc, L-03). */ export const NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry'; /** Jede Minute: prueft nur Clouds, deren erster Fehlschlag fuenf Minuten zurueckliegt. */ export const NEXTCLOUD_RETRY_CRON = '* * * * *'; /** * NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds * (quick-261002-k67, L-08, D-C). * * Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die * Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht * festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum * zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso. * * Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist * fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird * beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann * daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud * eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds * (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren * eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein * Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft. */ @Injectable() export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { private readonly logger = new Logger(NextcloudStatusSchedulerService.name); private running = false; private retryRunning = false; constructor( private readonly schedulerRegistry: SchedulerRegistry, private readonly service: NextcloudStatusService, private readonly release: NextcloudReleaseService, ) {} /** * Registriert und startet den Auftrag und stoesst das Aufwaermen der * Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und * protokolliert, nie weitergeworfen — die Anwendung startet trotzdem. */ async onApplicationBootstrap(): Promise { try { const job = new CronJobClass(NEXTCLOUD_CRON, () => { this.tick().catch((err) => this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`), ); }); // Cast noetig — dasselbe Muster wie ProxmoxSchedulerService. // eslint-disable-next-line @typescript-eslint/no-explicit-any // biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any); job.start(); this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`); const retryJob = new CronJobClass(NEXTCLOUD_RETRY_CRON, () => { this.retryTick().catch((err) => this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`), ); }); // biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService this.schedulerRegistry.addCronJob(NEXTCLOUD_RETRY_JOB_NAME, retryJob as any); retryJob.start(); this.logger.log(`Nextcloud-Status retry job registered: ${NEXTCLOUD_RETRY_CRON}`); void this.release.refresh().catch(() => undefined); } catch (err) { this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`); } } /** Ein Durchlauf ueber alle Clouds aller Mandanten. */ async tick(): Promise { if (this.running) { this.logger.warn('Nextcloud poll tick skipped — previous run still active'); return; } this.running = true; try { await this.checkRows(await this.service.loadAllInstancesForScheduler()); } finally { this.running = false; } } /** * Wiederholung: nur Clouds mit genau einem Fehlschlag, der mindestens * `RETRY_DELAY_MS` zurueckliegt. Wirft nie; ein laufender Durchlauf haelt * den naechsten an. */ async retryTick(now: Date = new Date()): Promise { if (this.retryRunning) { this.logger.warn('Nextcloud retry tick skipped — previous run still active'); return; } this.retryRunning = true; try { const rows = await this.service.loadAllInstancesForScheduler({ retryDueBefore: new Date(now.getTime() - RETRY_DELAY_MS), }); await this.checkRows(rows); } catch (err) { this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`); } finally { this.retryRunning = false; } } /** Prueft jede Cloud an ihren eigenen Mandanten gebunden, hoechstens vier gleichzeitig. */ private async checkRows(rows: { id: string; tenantId: string }[]): Promise { // Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt. const byTenant = new Map(); for (const row of rows) { const ids = byTenant.get(row.tenantId) ?? []; ids.push(row.id); byTenant.set(row.tenantId, ids); } const work: { tenantId: string; id: string }[] = []; for (const [tenantId, ids] of byTenant) { for (const id of ids) work.push({ tenantId, id }); } if (work.length === 0) return; const startedAt = Date.now(); await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => { try { await this.service.checkInstance(tenantId, id); } catch (err) { this.logger.error( `Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`, ); } }); this.logger.log( `Nextcloud-Pruefdurchlauf: ${work.length} Cloud(s) in ${Date.now() - startedAt} ms`, ); } }