2 Commits

Author SHA1 Message Date
fahricansecer 4c7930e9d2 feat: add watchdog timer to detect and recover from hung API requests
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
2026-04-25 11:20:30 +03:00
fahricansecer ec463cb927 fix: make canvas import optional for ARM64 compatibility 2026-04-25 02:41:53 +03:00
3 changed files with 47 additions and 2 deletions
+11
View File
@@ -43,6 +43,14 @@ export class FeederService {
private readonly MAX_RETRIES = 50; private readonly MAX_RETRIES = 50;
private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul"; private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul";
/** Watchdog heartbeat updated on every match/date activity */
public lastActivityAt: number = Date.now();
/** Call this to bump the heartbeat */
private heartbeat(): void {
this.lastActivityAt = Date.now();
}
constructor( constructor(
private readonly scraperService: FeederScraperService, private readonly scraperService: FeederScraperService,
private readonly transformerService: FeederTransformerService, private readonly transformerService: FeederTransformerService,
@@ -259,6 +267,7 @@ export class FeederService {
): Promise<void> { ): Promise<void> {
const { onlyCompletedMatches = false, refreshExistingMatches = false } = const { onlyCompletedMatches = false, refreshExistingMatches = false } =
options; options;
this.heartbeat();
this.logger.log(`[${sport}] 📅 Processing: ${dateString}`); this.logger.log(`[${sport}] 📅 Processing: ${dateString}`);
try { try {
@@ -431,6 +440,7 @@ export class FeederService {
refreshExistingMatches, refreshExistingMatches,
); );
this.heartbeat();
if (result.success) { if (result.success) {
this.logger.log( this.logger.log(
`[${sport}] ✅ successful for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`, `[${sport}] ✅ successful for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`,
@@ -443,6 +453,7 @@ export class FeederService {
failedMatches.push(match); failedMatches.push(match);
} }
} catch (e: any) { } catch (e: any) {
this.heartbeat();
this.logger.warn( this.logger.warn(
`[${sport}] Sequential error for ${match.id}: ${e.message}`, `[${sport}] Sequential error for ${match.id}: ${e.message}`,
); );
@@ -2,7 +2,16 @@ import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import axios from "axios"; import axios from "axios";
import { createCanvas, loadImage } from "canvas"; // Canvas is optional native module may fail on ARM64 (RPi)
let createCanvas: any;
let loadImage: any;
try {
const canvas = require("canvas");
createCanvas = canvas.createCanvas;
loadImage = canvas.loadImage;
} catch {
// Canvas unavailable ImageRendererService methods will throw at runtime if called
}
import { PredictionCardDto } from "./dto/prediction-card.dto"; import { PredictionCardDto } from "./dto/prediction-card.dto";
@Injectable() @Injectable()
+26 -1
View File
@@ -1,12 +1,19 @@
/** /**
* Run Full Historical Feeder * Run Full Historical Feeder
* Usage: npm run feeder:historical * Usage: npm run feeder:historical
*
* Includes a watchdog that kills the process if no activity
* is detected for 5 minutes (stuck API request), letting PM2
* auto-restart and resume from DB.
*/ */
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { FeederService } from "../modules/feeder/feeder.service"; import { FeederService } from "../modules/feeder/feeder.service";
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
const WATCHDOG_INTERVAL_MS = 60_000; // Check every 1 minute
const WATCHDOG_TIMEOUT_MS = 5 * 60_000; // Kill if no activity for 5 minutes
async function bootstrap() { async function bootstrap() {
process.env.FEEDER_MODE = "historical"; process.env.FEEDER_MODE = "historical";
@@ -21,8 +28,25 @@ async function bootstrap() {
logger: ["log", "error", "warn"], logger: ["log", "error", "warn"],
}); });
try {
const feederService = app.get(FeederService); const feederService = app.get(FeederService);
// ── Watchdog Timer ──────────────────────────────────────────
// If the feeder hangs on an API call for 5+ minutes, force-exit
// so PM2 can restart and resume from where it left off in DB.
const watchdog = setInterval(() => {
const idleMs = Date.now() - feederService.lastActivityAt;
if (idleMs > WATCHDOG_TIMEOUT_MS) {
logger.error(
`🐕 WATCHDOG: No activity for ${Math.round(idleMs / 1000)}s. Force-exiting for PM2 restart...`,
);
process.exit(1);
}
}, WATCHDOG_INTERVAL_MS);
// Don't let the watchdog timer keep the process alive after scan finishes
watchdog.unref();
try {
const startDate = process.env.FEEDER_START_DATE || "2023-06-01"; const startDate = process.env.FEEDER_START_DATE || "2023-06-01";
const sports = (process.env.FEEDER_SPORTS || "football,basketball") const sports = (process.env.FEEDER_SPORTS || "football,basketball")
.split(",") .split(",")
@@ -36,6 +60,7 @@ async function bootstrap() {
logger.error(error.stack); logger.error(error.stack);
process.exit(1); process.exit(1);
} finally { } finally {
clearInterval(watchdog);
await app.close(); await app.close();
} }