feat(wipes): wire the auto-wiper — scheduled wipes now actually fire
wipe_schedules rows existed but nothing read or fired them — an operator could
set a wipe schedule and it would never trigger (the headline auto-wipe feature
was inert; the manual trigger worked, the scheduler did not).
- WipesService now implements OnModuleInit/OnModuleDestroy with a 60s executor
(mirrors SchedulesService): bootstraps next_scheduled_run, then fires every
active schedule whose next_scheduled_run <= now via triggerWipe(...'scheduled')
-> instancesService.wipeForLicense -> the agent wipe handler, advancing
next_scheduled_run from the cron each cycle (advances even on failure so a
broken schedule can't re-fire every 60s).
- triggerWipe parameterized with triggerType ('manual' | 'scheduled') so
wipe_history records the real origin.
- Extracted nextCronDate into src/common/cron.util.ts (shared by the event and
wipe schedulers; was duplicated/private). Cron is evaluated UTC — the per-
schedule timezone column is still not honored, a known limitation shared by
both schedulers (follow-up: tz-aware cron lib).
Backend tsc green. Scheduling logic is at parity with the in-production event
scheduler; live end-to-end (a scheduled wipe deleting real files) verifies when
a game stack + agent are connected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
51
backend-nest/src/common/cron.util.ts
Normal file
51
backend-nest/src/common/cron.util.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Minimal 5-field cron "next run" calculator, shared by the event scheduler
|
||||||
|
* (SchedulesService) and the wipe scheduler (WipesService).
|
||||||
|
*
|
||||||
|
* Supports `*` and exact numeric fields (minute hour day-of-month month
|
||||||
|
* day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a
|
||||||
|
* malformed expression or if no match is found within a year.
|
||||||
|
*
|
||||||
|
* NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone`
|
||||||
|
* column exists on both schedule tables but is NOT yet honored here — fixing it
|
||||||
|
* properly needs a timezone-aware cron library; tracked as a shared follow-up.
|
||||||
|
*/
|
||||||
|
export function nextCronDate(expr: string, after: Date): Date | null {
|
||||||
|
const parts = expr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return null;
|
||||||
|
|
||||||
|
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||||
|
|
||||||
|
const matches = (e: string, value: number): boolean => {
|
||||||
|
if (e === '*') return true;
|
||||||
|
return parseInt(e, 10) === value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk minute-by-minute up to 366 days forward to find the next match.
|
||||||
|
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||||
|
candidate.setSeconds(0, 0);
|
||||||
|
|
||||||
|
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
while (candidate < limit) {
|
||||||
|
const min = candidate.getUTCMinutes();
|
||||||
|
const hour = candidate.getUTCHours();
|
||||||
|
const dom = candidate.getUTCDate();
|
||||||
|
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||||
|
const dow = candidate.getUTCDay(); // 0=Sun
|
||||||
|
|
||||||
|
if (
|
||||||
|
matches(minuteExpr, min) &&
|
||||||
|
matches(hourExpr, hour) &&
|
||||||
|
matches(domExpr, dom) &&
|
||||||
|
matches(monthExpr, month) &&
|
||||||
|
matches(dowExpr, dow)
|
||||||
|
) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.setTime(candidate.getTime() + 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -11,47 +11,7 @@ import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
|||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { nextCronDate } from '../../common/cron.util';
|
||||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
|
||||||
function nextCronDate(expr: string, after: Date): Date | null {
|
|
||||||
const parts = expr.trim().split(/\s+/);
|
|
||||||
if (parts.length !== 5) return null;
|
|
||||||
|
|
||||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
|
||||||
|
|
||||||
function matches(expr: string, value: number): boolean {
|
|
||||||
if (expr === '*') return true;
|
|
||||||
return parseInt(expr, 10) === value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
|
||||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
|
||||||
candidate.setSeconds(0, 0);
|
|
||||||
|
|
||||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
while (candidate < limit) {
|
|
||||||
const min = candidate.getUTCMinutes();
|
|
||||||
const hour = candidate.getUTCHours();
|
|
||||||
const dom = candidate.getUTCDate();
|
|
||||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
|
||||||
const dow = candidate.getUTCDay(); // 0=Sun
|
|
||||||
|
|
||||||
if (
|
|
||||||
matches(minuteExpr, min) &&
|
|
||||||
matches(hourExpr, hour) &&
|
|
||||||
matches(domExpr, dom) &&
|
|
||||||
matches(monthExpr, month) &&
|
|
||||||
matches(dowExpr, dow)
|
|
||||||
) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
candidate.setTime(candidate.getTime() + 60_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||||
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
||||||
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
||||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||||
@@ -9,10 +15,12 @@ import { UpdateProfileDto } from './dto/update-profile.dto';
|
|||||||
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { nextCronDate } from '../../common/cron.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WipesService {
|
export class WipesService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(WipesService.name);
|
private readonly logger = new Logger(WipesService.name);
|
||||||
|
private wipeExecutorInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(WipeProfile)
|
@InjectRepository(WipeProfile)
|
||||||
@@ -24,6 +32,82 @@ export class WipesService {
|
|||||||
private readonly instancesService: InstancesService,
|
private readonly instancesService: InstancesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduled-wipe executor — the auto-wiper. Mirrors SchedulesService: a 60s
|
||||||
|
// poll fires every active wipe schedule whose next_scheduled_run is due, then
|
||||||
|
// advances it from its cron expression. Without this, wipe_schedules rows
|
||||||
|
// never fire (the headline auto-wipe feature was inert).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.bootstrapWipeSchedules().catch((err) =>
|
||||||
|
this.logger.error('Failed to bootstrap wipe-schedule next runs', err),
|
||||||
|
);
|
||||||
|
this.wipeExecutorInterval = setInterval(() => {
|
||||||
|
this.executeDueWipes().catch((err) =>
|
||||||
|
this.logger.error('Wipe-schedule executor error', err),
|
||||||
|
);
|
||||||
|
}, 60_000);
|
||||||
|
this.logger.log('Wipe-schedule executor started (60s polling interval)');
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.wipeExecutorInterval) {
|
||||||
|
clearInterval(this.wipeExecutorInterval);
|
||||||
|
this.wipeExecutorInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On startup, stamp next_scheduled_run on active schedules that lack one. */
|
||||||
|
private async bootstrapWipeSchedules(): Promise<void> {
|
||||||
|
const schedules = await this.wipeScheduleRepo.find({
|
||||||
|
where: { is_active: true, next_scheduled_run: IsNull() },
|
||||||
|
});
|
||||||
|
for (const s of schedules) {
|
||||||
|
const next = nextCronDate(s.cron_expression, new Date());
|
||||||
|
if (next) {
|
||||||
|
s.next_scheduled_run = next;
|
||||||
|
await this.wipeScheduleRepo.save(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schedules.length > 0) {
|
||||||
|
this.logger.log(`Bootstrapped next run for ${schedules.length} wipe schedule(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire every active wipe schedule whose next_scheduled_run <= now. */
|
||||||
|
private async executeDueWipes(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const due = await this.wipeScheduleRepo.find({
|
||||||
|
where: { is_active: true, next_scheduled_run: LessThanOrEqual(now) },
|
||||||
|
});
|
||||||
|
if (due.length === 0) return;
|
||||||
|
|
||||||
|
this.logger.log(`Executing ${due.length} due wipe schedule(s)`);
|
||||||
|
for (const s of due) {
|
||||||
|
try {
|
||||||
|
await this.triggerWipe(
|
||||||
|
s.license_id,
|
||||||
|
{
|
||||||
|
wipe_type: s.wipe_type as TriggerWipeDto['wipe_type'],
|
||||||
|
wipe_profile_id: s.wipe_profile_id,
|
||||||
|
},
|
||||||
|
'scheduled',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Scheduled wipe failed for schedule ${s.id} (${s.schedule_name})`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Advance next_scheduled_run regardless, so a failing schedule doesn't
|
||||||
|
// re-fire every 60s.
|
||||||
|
s.next_scheduled_run = nextCronDate(s.cron_expression, now);
|
||||||
|
await this.wipeScheduleRepo.save(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
||||||
return this.wipeProfileRepo.find({
|
return this.wipeProfileRepo.find({
|
||||||
where: { license_id: licenseId },
|
where: { license_id: licenseId },
|
||||||
@@ -96,19 +180,22 @@ export class WipesService {
|
|||||||
async triggerWipe(
|
async triggerWipe(
|
||||||
licenseId: string,
|
licenseId: string,
|
||||||
dto: TriggerWipeDto,
|
dto: TriggerWipeDto,
|
||||||
|
triggerType: 'manual' | 'scheduled' = 'manual',
|
||||||
): Promise<{ wipe_history_id: string }> {
|
): Promise<{ wipe_history_id: string }> {
|
||||||
const history = this.wipeHistoryRepo.create({
|
const history = this.wipeHistoryRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
wipe_type: dto.wipe_type,
|
wipe_type: dto.wipe_type,
|
||||||
wipe_profile_id: dto.wipe_profile_id,
|
wipe_profile_id: dto.wipe_profile_id,
|
||||||
trigger_type: 'manual',
|
trigger_type: triggerType,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.wipeHistoryRepo.save(history);
|
const saved = await this.wipeHistoryRepo.save(history);
|
||||||
|
|
||||||
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
|
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
|
||||||
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
|
this.logger.log(
|
||||||
|
`Wipe ${triggerType === 'scheduled' ? 'scheduled' : 'triggered'} for license ${licenseId} — history id ${saved.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
return { wipe_history_id: saved.id };
|
return { wipe_history_id: saved.id };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user