Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- Migration: add_feature_flags_and_cron_jobs
-- Adds FeatureFlag and CronJobRun models

-- FeatureFlag: stores named feature toggles with optional rollout percentage
CREATE TABLE "feature_flags" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"description" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"rolloutPercent" INTEGER NOT NULL DEFAULT 100,
"allowedRoles" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"allowedUserIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "feature_flags_key_key" ON "feature_flags"("key");

-- CronJobRun: one row per execution of a named cron job
CREATE TYPE "CronJobStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED', 'SKIPPED');

CREATE TABLE "cron_job_runs" (
"id" TEXT NOT NULL,
"jobName" TEXT NOT NULL,
"status" "CronJobStatus" NOT NULL DEFAULT 'RUNNING',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"durationMs" INTEGER,
"message" TEXT,
"error" TEXT,
"metadata" JSONB,

CONSTRAINT "cron_job_runs_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "cron_job_runs_jobName_idx" ON "cron_job_runs"("jobName");
CREATE INDEX "cron_job_runs_startedAt_idx" ON "cron_job_runs"("startedAt");
CREATE INDEX "cron_job_runs_status_idx" ON "cron_job_runs"("status");
47 changes: 47 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1359,3 +1359,50 @@ model CourseDraft {
@@map("course_drafts")
}


// ============================================================
// FEATURE FLAGS
// ============================================================

model FeatureFlag {
id String @id @default(uuid())
key String @unique // e.g. "new_payment_flow", "stellar_v2_calls"
description String?
enabled Boolean @default(false)
rolloutPercent Int @default(100) // 0-100 — percentage of users that see the flag
allowedRoles String[] @default([]) // restrict to specific roles (empty = all roles)
allowedUserIds String[] @default([]) // explicit user allow-list (empty = no extra restriction)
metadata Json? // arbitrary extra config for the flag
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@map("feature_flags")
}

// ============================================================
// CRON JOB MONITORING
// ============================================================

enum CronJobStatus {
RUNNING
SUCCESS
FAILED
SKIPPED
}

model CronJobRun {
id String @id @default(uuid())
jobName String // e.g. "purge-audit-logs", "payout-scheduler"
status CronJobStatus @default(RUNNING)
startedAt DateTime @default(now())
finishedAt DateTime?
durationMs Int? // wall-clock time in milliseconds
message String? // human-readable outcome summary
error String? // error message / stack if FAILED
metadata Json? // job-specific output (rows affected, etc.)

@@index([jobName])
@@index([startedAt])
@@index([status])
@@map("cron_job_runs")
}
87 changes: 87 additions & 0 deletions src/modules/cron-monitor/cron-monitor.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { UserRole } from '@prisma/client';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Roles, RolesGuard } from '../../common/guards/roles.guard';
import { CronMonitorService } from './cron-monitor.service';
import { QueryCronRunsDto } from './dto/query-cron-runs.dto';

@ApiTags('cron-monitor')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Controller('cron-monitor')
export class CronMonitorController {
constructor(private readonly cronMonitorService: CronMonitorService) {}

@Get('runs')
@ApiOperation({
summary: 'List cron job run history with pagination and filters (admin)',
})
findAll(@Query() query: QueryCronRunsDto) {
return this.cronMonitorService.findAll(query);
}

@Get('stats')
@ApiOperation({
summary:
'Aggregated stats per job: success rate, avg duration, last run/success/failure (30-day window)',
})
getStats() {
return this.cronMonitorService.getStats();
}

@Get('stale')
@ApiOperation({
summary:
'List RUNNING cron job runs that have been active longer than the stale threshold (admin)',
})
@ApiQuery({
name: 'thresholdMinutes',
required: false,
description: 'Minutes after which a RUNNING run is considered stale (default: 30)',
})
findStale(@Query('thresholdMinutes') thresholdMinutes?: string) {
return this.cronMonitorService.findStaleRuns(
thresholdMinutes ? parseInt(thresholdMinutes, 10) : undefined,
);
}

@Post('stale/mark-failed')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Manually trigger stale-run cleanup — marks zombie RUNNING runs as FAILED (admin)',
})
@ApiQuery({
name: 'thresholdMinutes',
required: false,
description: 'Minutes threshold (default: 30)',
})
markStaleFailed(@Query('thresholdMinutes') thresholdMinutes?: string) {
return this.cronMonitorService.markStaleRunsFailed(
thresholdMinutes ? parseInt(thresholdMinutes, 10) : undefined,
);
}

@Get('runs/:id')
@ApiOperation({ summary: 'Get a single cron job run by ID (admin)' })
@ApiParam({ name: 'id', description: 'CronJobRun UUID' })
findOne(@Param('id') id: string) {
return this.cronMonitorService.findOne(id);
}
}
13 changes: 13 additions & 0 deletions src/modules/cron-monitor/cron-monitor.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CronMonitorService } from './cron-monitor.service';
import { CronMonitorController } from './cron-monitor.controller';
import { CronMonitorScheduler } from './cron-monitor.scheduler';

@Module({
imports: [ConfigModule],
controllers: [CronMonitorController],
providers: [CronMonitorService, CronMonitorScheduler],
exports: [CronMonitorService], // export so any cron service can call .start() / .run()
})
export class CronMonitorModule {}
46 changes: 46 additions & 0 deletions src/modules/cron-monitor/cron-monitor.scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { CronMonitorService } from './cron-monitor.service';

/**
* CronMonitorScheduler
*
* Houses the housekeeping cron jobs for the cron monitor itself:
* - Every 15 minutes: mark zombie RUNNING runs as FAILED
* - Daily at midnight: purge old run history (configurable retention)
*/
@Injectable()
export class CronMonitorScheduler {
private readonly logger = new Logger(CronMonitorScheduler.name);

constructor(
private readonly cronMonitor: CronMonitorService,
private readonly config: ConfigService,
) {}

@Cron('0 */15 * * * *', { name: 'cron-monitor-stale-cleanup' })
async cleanupStaleRuns() {
const thresholdMinutes = this.config.get<number>(
'CRON_STALE_THRESHOLD_MINUTES',
30,
);
const result = await this.cronMonitor.markStaleRunsFailed(thresholdMinutes);
if (result.marked > 0) {
this.logger.warn(`Cleaned up ${result.marked} stale cron run(s)`);
}
}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { name: 'cron-monitor-purge' })
async purgeHistory() {
const retentionDays = this.config.get<number>(
'CRON_RUN_RETENTION_DAYS',
90,
);
await this.cronMonitor.run('cron-monitor-purge', async () => {
const result = await this.cronMonitor.purgeOldRuns(retentionDays);
this.logger.log(`Purged ${result.deleted} old cron run records`);
return result;
});
}
}
Loading