2 Commits

Author SHA1 Message Date
fahricansecer a338d02244 main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m42s
2026-04-26 03:07:18 +03:00
fahricansecer 1623432039 fix: watchdog force-kill with SIGKILL fallback when process.exit is blocked 2026-04-26 02:27:51 +03:00
21 changed files with 838 additions and 164 deletions
File diff suppressed because one or more lines are too long
+1
View File
@@ -22,6 +22,7 @@
"ai:backtest": "python ai-engine/scripts/backtest_v2_runtime.py", "ai:backtest": "python ai-engine/scripts/backtest_v2_runtime.py",
"ai:train:vqwen": "python ai-engine/scripts/train_vqwen_v3.py", "ai:train:vqwen": "python ai-engine/scripts/train_vqwen_v3.py",
"feeder:historical": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts", "feeder:historical": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts",
"feeder:repair": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-repair.ts",
"feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-previous-day.ts", "feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-previous-day.ts",
"feeder:fill-gaps": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-filtered.ts", "feeder:fill-gaps": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-filtered.ts",
"feeder:basketball": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-basketball.ts", "feeder:basketball": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-basketball.ts",
+2
View File
@@ -50,6 +50,7 @@ import { LeaguesModule } from "./modules/leagues/leagues.module";
import { AnalysisModule } from "./modules/analysis/analysis.module"; import { AnalysisModule } from "./modules/analysis/analysis.module";
import { CouponsModule } from "./modules/coupons/coupons.module"; import { CouponsModule } from "./modules/coupons/coupons.module";
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module"; import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module";
// Services and Tasks // Services and Tasks
import { ServicesModule } from "./services/services.module"; import { ServicesModule } from "./services/services.module";
@@ -201,6 +202,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
AnalysisModule, AnalysisModule,
CouponsModule, CouponsModule,
SporTotoModule, SporTotoModule,
AiProxyModule,
// Services and Scheduled Tasks // Services and Scheduled Tasks
ServicesModule, ServicesModule,
+1 -1
View File
@@ -22,7 +22,7 @@ export const envSchema = z.object({
// Database // Database
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
// AI Engine // AI Engine
AI_ENGINE_URL: z.string().url().default("http://localhost:8000"), AI_ENGINE_URL: z.string().url(),
AI_ENGINE_MODE: z.enum(["v28-pro-max", "dual"]).default("v28-pro-max"), AI_ENGINE_MODE: z.enum(["v28-pro-max", "dual"]).default("v28-pro-max"),
// JWT // JWT
+12 -1
View File
@@ -18,7 +18,7 @@ import {
CACHE_MANAGER, CACHE_MANAGER,
} from "@nestjs/cache-manager"; } from "@nestjs/cache-manager";
import * as cacheManager from "cache-manager"; import * as cacheManager from "cache-manager";
import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse as SwaggerResponse } from "@nestjs/swagger";
import { Roles } from "../../common/decorators"; import { Roles } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service"; import { PrismaService } from "../../database/prisma.service";
import { PaginationDto } from "../../common/dto/pagination.dto"; import { PaginationDto } from "../../common/dto/pagination.dto";
@@ -46,6 +46,7 @@ export class AdminController {
@Get("users") @Get("users")
@ApiOperation({ summary: "Get all users (admin)" }) @ApiOperation({ summary: "Get all users (admin)" })
@SwaggerResponse({ status: 200, type: [UserResponseDto] })
async getAllUsers( async getAllUsers(
@Query() pagination: PaginationDto, @Query() pagination: PaginationDto,
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> { ): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
@@ -75,6 +76,7 @@ export class AdminController {
@Get("users/:id") @Get("users/:id")
@ApiOperation({ summary: "Get user by ID" }) @ApiOperation({ summary: "Get user by ID" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async getUserById( async getUserById(
@Param("id") id: string, @Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> { ): Promise<ApiResponse<UserResponseDto>> {
@@ -98,6 +100,7 @@ export class AdminController {
@Put("users/:id/toggle-active") @Put("users/:id/toggle-active")
@ApiOperation({ summary: "Toggle user active status" }) @ApiOperation({ summary: "Toggle user active status" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async toggleUserActive( async toggleUserActive(
@Param("id") id: string, @Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> { ): Promise<ApiResponse<UserResponseDto>> {
@@ -120,6 +123,7 @@ export class AdminController {
@Put("users/:id/role") @Put("users/:id/role")
@ApiOperation({ summary: "Update user role" }) @ApiOperation({ summary: "Update user role" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async updateUserRole( async updateUserRole(
@Param("id") id: string, @Param("id") id: string,
@Body() data: { role: UserRole }, @Body() data: { role: UserRole },
@@ -137,6 +141,7 @@ export class AdminController {
@Put("users/:id/subscription") @Put("users/:id/subscription")
@ApiOperation({ summary: "Update user subscription" }) @ApiOperation({ summary: "Update user subscription" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async updateUserSubscription( async updateUserSubscription(
@Param("id") id: string, @Param("id") id: string,
@Body() @Body()
@@ -160,6 +165,7 @@ export class AdminController {
@Delete("users/:id") @Delete("users/:id")
@ApiOperation({ summary: "Soft delete a user" }) @ApiOperation({ summary: "Soft delete a user" })
@SwaggerResponse({ status: 200, description: "User deleted" })
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> { async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
await this.prisma.user.update({ await this.prisma.user.update({
where: { id }, where: { id },
@@ -175,6 +181,7 @@ export class AdminController {
@CacheKey("app_settings") @CacheKey("app_settings")
@CacheTTL(60 * 1000) @CacheTTL(60 * 1000)
@ApiOperation({ summary: "Get all app settings" }) @ApiOperation({ summary: "Get all app settings" })
@SwaggerResponse({ status: 200, schema: { type: "object", additionalProperties: { type: "string" } } })
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> { async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
const settings = await this.prisma.appSetting.findMany(); const settings = await this.prisma.appSetting.findMany();
const settingsMap: Record<string, string> = {}; const settingsMap: Record<string, string> = {};
@@ -186,6 +193,7 @@ export class AdminController {
@Put("settings/:key") @Put("settings/:key")
@ApiOperation({ summary: "Update an app setting" }) @ApiOperation({ summary: "Update an app setting" })
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" } } } })
async updateSetting( async updateSetting(
@Param("key") key: string, @Param("key") key: string,
@Body() data: { value: string }, @Body() data: { value: string },
@@ -206,6 +214,7 @@ export class AdminController {
@Get("usage-limits") @Get("usage-limits")
@ApiOperation({ summary: "Get all usage limits" }) @ApiOperation({ summary: "Get all usage limits" })
@SwaggerResponse({ status: 200, schema: { type: "array", items: { type: "object" } } })
async getAllUsageLimits(@Query() pagination: PaginationDto) { async getAllUsageLimits(@Query() pagination: PaginationDto) {
const { skip, take } = pagination; const { skip, take } = pagination;
@@ -233,6 +242,7 @@ export class AdminController {
@Post("usage-limits/reset-all") @Post("usage-limits/reset-all")
@ApiOperation({ summary: "Reset all usage limits" }) @ApiOperation({ summary: "Reset all usage limits" })
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { count: { type: "number" } } } })
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> { async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
const result = await this.prisma.usageLimit.updateMany({ const result = await this.prisma.usageLimit.updateMany({
data: { data: {
@@ -252,6 +262,7 @@ export class AdminController {
@Get("analytics/overview") @Get("analytics/overview")
@ApiOperation({ summary: "Get system analytics overview" }) @ApiOperation({ summary: "Get system analytics overview" })
@SwaggerResponse({ status: 200, schema: { type: "object" } })
async getAnalyticsOverview() { async getAnalyticsOverview() {
const [ const [
totalUsers, totalUsers,
@@ -0,0 +1,20 @@
import { All, Body, Controller, Req } from "@nestjs/common";
import type { Request } from "express";
import { AiProxyService } from "./ai-proxy.service";
@Controller("ai-engine")
export class AiProxyController {
constructor(private readonly aiProxyService: AiProxyService) {}
@All("*path")
proxy(@Req() request: Request, @Body() body: unknown) {
return this.aiProxyService.proxy({
method: request.method,
originalUrl: request.originalUrl,
query: request.query as Record<string, unknown>,
body,
acceptLanguage: request.headers["accept-language"],
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { AiProxyController } from "./ai-proxy.controller";
import { AiProxyService } from "./ai-proxy.service";
@Module({
imports: [
HttpModule.register({
timeout: 45000,
maxRedirects: 0,
}),
],
controllers: [AiProxyController],
providers: [AiProxyService],
})
export class AiProxyModule {}
+98
View File
@@ -0,0 +1,98 @@
import {
BadGatewayException,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { AxiosError, Method } from "axios";
interface ProxyRequest {
method: string;
originalUrl: string;
query: Record<string, unknown>;
body: unknown;
acceptLanguage?: string | string[];
}
interface AllowedRoute {
method: Method;
pattern: RegExp;
}
const ALLOWED_AI_ROUTES: AllowedRoute[] = [
{ method: "GET", pattern: /^\/$/ },
{ method: "GET", pattern: /^\/health$/ },
{ method: "POST", pattern: /^\/v20plus\/analyze\/[^/]+$/ },
{ method: "GET", pattern: /^\/v20plus\/analyze-htms\/[^/]+$/ },
{ method: "GET", pattern: /^\/v20plus\/analyze-htft\/[^/]+$/ },
{ method: "POST", pattern: /^\/v20plus\/coupon$/ },
{ method: "GET", pattern: /^\/v20plus\/daily-banker$/ },
{ method: "GET", pattern: /^\/v20plus\/reversal-watchlist$/ },
{ method: "GET", pattern: /^\/v2\/health$/ },
{ method: "POST", pattern: /^\/v2\/analyze\/[^/]+$/ },
];
@Injectable()
export class AiProxyService {
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
async proxy(request: ProxyRequest) {
const path = this.extractProxyPath(request.originalUrl);
const method = request.method.toUpperCase() as Method;
if (!this.isAllowed(method, path)) {
throw new ForbiddenException("AI_PROXY_ROUTE_NOT_ALLOWED");
}
const baseUrl = this.configService.getOrThrow<string>("AI_ENGINE_URL");
const targetUrl = new URL(path, baseUrl);
try {
const response = await this.httpService.axiosRef.request({
url: targetUrl.toString(),
method,
params: request.query,
data: request.body,
headers: {
"content-type": "application/json",
"accept-language": Array.isArray(request.acceptLanguage)
? request.acceptLanguage[0]
: request.acceptLanguage,
},
timeout: 45000,
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 500,
});
return response.data;
} catch (error) {
const axiosError = error as AxiosError;
throw new BadGatewayException({
message: "AI_PROXY_UPSTREAM_FAILED",
status: axiosError.response?.status,
});
}
}
private extractProxyPath(originalUrl: string): string {
const withoutQuery = originalUrl.split("?")[0] || "";
const marker = "/ai-engine";
const markerIndex = withoutQuery.indexOf(marker);
if (markerIndex === -1) {
return "/";
}
const path = withoutQuery.slice(markerIndex + marker.length);
return path.length === 0 ? "/" : path;
}
private isAllowed(method: Method, path: string): boolean {
return ALLOWED_AI_ROUTES.some(
(route) => route.method === method && route.pattern.test(path),
);
}
}
+23 -2
View File
@@ -30,7 +30,18 @@ export class AnalysisController {
@Post("analyze-matches") @Post("analyze-matches")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Analyze multiple matches for coupon" }) @ApiOperation({ summary: "Analyze multiple matches for coupon" })
@ApiResponse({ status: 200, description: "Analysis successful" }) @ApiResponse({
status: 200,
description: "Analysis successful",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
@ApiResponse({ status: 400, description: "Invalid input" }) @ApiResponse({ status: 400, description: "Invalid input" })
@ApiResponse({ status: 429, description: "Usage limit exceeded" }) @ApiResponse({ status: 429, description: "Usage limit exceeded" })
async analyzeMatches( async analyzeMatches(
@@ -92,7 +103,17 @@ export class AnalysisController {
*/ */
@Get("history") @Get("history")
@ApiOperation({ summary: "Get analysis history" }) @ApiOperation({ summary: "Get analysis history" })
@ApiResponse({ status: 200, description: "History retrieved" }) @ApiResponse({
status: 200,
description: "History retrieved",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "array", items: { type: "object" } },
},
},
})
async getHistory(@CurrentUser() user: any) { async getHistory(@CurrentUser() user: any) {
const history = await this.analysisService.getAnalysisHistory(user.id); const history = await this.analysisService.getAnalysisHistory(user.id);
return { success: true, data: history }; return { success: true, data: history };
+11 -1
View File
@@ -67,7 +67,17 @@ export class AuthController {
@Post("logout") @Post("logout")
@HttpCode(200) @HttpCode(200)
@ApiOperation({ summary: "Logout and invalidate refresh token" }) @ApiOperation({ summary: "Logout and invalidate refresh token" })
@ApiOkResponse({ description: "Logout successful" }) @ApiOkResponse({
description: "Logout successful",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
data: { type: "null" },
},
},
})
async logout( async logout(
@Body() dto: RefreshTokenDto, @Body() dto: RefreshTokenDto,
@I18n() i18n: I18nContext, @I18n() i18n: I18nContext,
+72 -3
View File
@@ -53,7 +53,18 @@ export class CouponsController {
@Public() @Public()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Analyze single match with V20 model" }) @ApiOperation({ summary: "Analyze single match with V20 model" })
@ApiResponse({ status: 200, description: "Match analysis" }) @ApiResponse({
status: 200,
description: "Match analysis",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async analyzeMatch(@Body() dto: AnalyzeMatchDto) { async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId); const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
if (!analysis) { if (!analysis) {
@@ -99,6 +110,18 @@ export class CouponsController {
@ApiOperation({ @ApiOperation({
summary: "Generate a high-confidence banko combo (2 matches)", summary: "Generate a high-confidence banko combo (2 matches)",
}) })
@ApiResponse({
status: 200,
description: "Daily banko coupon",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async getDailyBanko(@Body() dto: DailyBankoDto) { async getDailyBanko(@Body() dto: DailyBankoDto) {
// If no match IDs provided, fetch from system (top 50 upcoming) // If no match IDs provided, fetch from system (top 50 upcoming)
let candidateMatches = dto.matchIds || []; let candidateMatches = dto.matchIds || [];
@@ -146,7 +169,18 @@ export class CouponsController {
@Public() @Public()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Suggest Smart Coupon" }) @ApiOperation({ summary: "Suggest Smart Coupon" })
@ApiResponse({ status: 200, description: "Smart Coupon generated" }) @ApiResponse({
status: 200,
description: "Smart Coupon generated",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async suggestCoupon(@Body() dto: SuggestCouponDto) { async suggestCoupon(@Body() dto: SuggestCouponDto) {
// If no match IDs provided, fetch from system (top 50 upcoming) // If no match IDs provided, fetch from system (top 50 upcoming)
let candidateMatches = dto.matchIds || []; let candidateMatches = dto.matchIds || [];
@@ -237,6 +271,18 @@ export class CouponsController {
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create and save a user coupon" }) @ApiOperation({ summary: "Create and save a user coupon" })
@ApiResponse({
status: 201,
description: "Coupon created",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) { async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
// req.user is populated by JwtAuthGuard // req.user is populated by JwtAuthGuard
const coupon = await this.userCouponService.createCoupon(req.user, dto); const coupon = await this.userCouponService.createCoupon(req.user, dto);
@@ -251,6 +297,18 @@ export class CouponsController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: "Get user betting statistics" }) @ApiOperation({ summary: "Get user betting statistics" })
@ApiResponse({
status: 200,
description: "User statistics",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async getUserStats(@Req() req: any) { async getUserStats(@Req() req: any) {
const stats = await this.userCouponService.getUserStatistics(req.user.id); const stats = await this.userCouponService.getUserStatistics(req.user.id);
return { success: true, data: stats }; return { success: true, data: stats };
@@ -263,7 +321,18 @@ export class CouponsController {
@Get("history") @Get("history")
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: "Get coupon history" }) @ApiOperation({ summary: "Get coupon history" })
@ApiResponse({ status: 200, description: "History retrieved" }) @ApiResponse({
status: 200,
description: "History retrieved",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "array", items: { type: "object" } },
message: { type: "string" },
},
},
})
async getHistory(@Query("limit") limit?: string) { async getHistory(@Query("limit") limit?: string) {
// eslint-disable-next-line @typescript-eslint/await-thenable // eslint-disable-next-line @typescript-eslint/await-thenable
const results = await this.couponsService.getCouponHistory( const results = await this.couponsService.getCouponHistory(
@@ -898,6 +898,58 @@ export class FeederPersistenceService {
.map((m) => m.id); .map((m) => m.id);
} }
/**
* For a list of match IDs that ALREADY exist in DB,
* returns which data scopes are missing per match.
* Only checks completed (Ended) football/basketball matches.
*/
async getMissingScopes(
matchIds: string[],
): Promise<Map<string, string[]>> {
const result = new Map<string, string[]>();
if (matchIds.length === 0) return result;
const matches = await this.prisma.match.findMany({
where: {
id: { in: matchIds },
state: "Ended",
},
select: {
id: true,
sport: true,
_count: {
select: {
playerParticipations: true,
footballTeamStats: true,
basketballTeamStats: true,
basketballPlayerStats: true,
oddCategories: true,
},
},
},
});
for (const m of matches) {
const missing: string[] = [];
if (m.sport === "football") {
if (m._count.footballTeamStats === 0) missing.push("stats");
if (m._count.playerParticipations < 18) missing.push("lineups");
} else if (m.sport === "basketball") {
if (m._count.basketballTeamStats === 0) missing.push("stats");
if (m._count.basketballPlayerStats === 0) missing.push("lineups");
}
if (m._count.oddCategories === 0) missing.push("odds");
if (missing.length > 0) {
result.set(m.id, missing);
}
}
return result;
}
async hasOdds(matchId: string): Promise<boolean> { async hasOdds(matchId: string): Promise<boolean> {
const category = await this.prisma.oddCategory.findFirst({ const category = await this.prisma.oddCategory.findFirst({
where: { matchId }, where: { matchId },
+95 -36
View File
@@ -385,21 +385,71 @@ export class FeederService {
return; return;
} }
// 2. Filter out already existing matches to skip processing // 2. Filter out already existing matches & patch incomplete ones
const allIds = matchesToProcess.map((m) => m.id); const allIds = matchesToProcess.map((m) => m.id);
const existingIds = const existingIds =
await this.persistenceService.getExistingMatchIds(allIds); await this.persistenceService.getExistingMatchIds(allIds);
const totalCount = matchesToProcess.length; const totalCount = matchesToProcess.length;
// ── Patch incomplete existing matches ──────────────────────
// Find matches that ARE in DB but have missing data scopes
const allExistingInDb = await this.persistenceService.getMissingScopes(allIds);
if (allExistingInDb.size > 0) {
this.logger.log(
`[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`,
);
for (const [matchId, missingScopes] of allExistingInDb) {
const matchSummary = matchesToProcess.find((m) => m.id === matchId);
if (!matchSummary) continue;
for (const scope of missingScopes) {
await this.delay(500);
try {
const patchScope: "all" | "lineups" | "odds" =
scope === "odds" ? "odds" : scope === "lineups" ? "lineups" : "all";
const result = await this.processSingleMatch(
matchSummary,
data.competitions,
sport,
true, // force
patchScope,
);
this.heartbeat();
if (result.success) {
this.logger.log(
`[${sport}] ✅ Patched [${scope}] for ${matchId} ${matchSummary.homeTeam.name} vs ${matchSummary.awayTeam.name}`,
);
} else {
this.logger.warn(
`[${sport}] ⚠️ Patch [${scope}] failed for ${matchId}`,
);
}
} catch (e: any) {
this.logger.warn(
`[${sport}] ❌ Patch [${scope}] exception for ${matchId}: ${e.message}`,
);
}
}
}
}
// ─────────────────────────────────────────────────────────────
// Now filter out COMPLETE existing matches (skip them)
if (!refreshExistingMatches && existingIds.length > 0) { if (!refreshExistingMatches && existingIds.length > 0) {
// Re-check after patching - which ones are now complete?
const updatedExistingIds =
await this.persistenceService.getExistingMatchIds(allIds);
matchesToProcess = matchesToProcess.filter( matchesToProcess = matchesToProcess.filter(
(m) => !existingIds.includes(m.id), (m) => !updatedExistingIds.includes(m.id),
); );
} }
if (matchesToProcess.length === 0) { if (matchesToProcess.length === 0) {
this.logger.log( this.logger.log(
`[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`, `[${sport}] [${dateString}] All ${totalCount} matches processed (${existingIds.length} existed, ${allExistingInDb.size} patched). Done.`,
); );
return; return;
} }
@@ -410,7 +460,7 @@ export class FeederService {
); );
} else { } else {
this.logger.log( this.logger.log(
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`, `[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} new matches (${existingIds.length} existing, ${allExistingInDb.size} patched)`,
); );
} }
@@ -474,7 +524,7 @@ export class FeederService {
match, match,
data.competitions, data.competitions,
sport, sport,
refreshExistingMatches, true, // FORCE: re-fetch incomplete data
); );
if (result.success) { if (result.success) {
successCount++; successCount++;
@@ -778,8 +828,9 @@ export class FeederService {
if (scope === "all" || scope === "lineups") { if (scope === "all" || scope === "lineups") {
// Starting Formation // Starting Formation
try { try {
const formationData = const formationData = await fetchResilient("Formation", () =>
await this.scraperService.fetchStartingFormation(matchId); this.scraperService.fetchStartingFormation(matchId),
);
if (formationData?.stats) { if (formationData?.stats) {
this.transformerService.processLineup( this.transformerService.processLineup(
formationData.stats.home || [], formationData.stats.home || [],
@@ -805,8 +856,9 @@ export class FeederService {
// Substitutes // Substitutes
try { try {
const subsData = const subsData = await fetchResilient("Subs", () =>
await this.scraperService.fetchSubstitutions(matchId); this.scraperService.fetchSubstitutions(matchId),
);
if (subsData?.stats) { if (subsData?.stats) {
this.transformerService.processLineup( this.transformerService.processLineup(
subsData.stats.home || [], subsData.stats.home || [],
@@ -887,7 +939,37 @@ export class FeederService {
} }
} }
// 4. Persist to Database // ── Pre-save completeness gate ──────────────────────────────
// If a 502 caused missing data, do NOT save. The data exists on
// the API and will be available shortly. Skip and retry instead.
const completedMatch = isMatchCompleted({
state: headerData?.matchStatus ?? matchSummary.state,
status: matchSummary.status,
substate: matchSummary.substate,
statusBoxContent: matchSummary.statusBoxContent,
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
});
const missingParts: string[] = [];
if (scope === "all" && completedMatch) {
if (sport === "football" && !stats) missingParts.push("Stats");
if (sport === "football" && participationData.length < 18)
missingParts.push("Lineups");
if (sport === "basketball" && !basketballTeamStats)
missingParts.push("BoxScore");
if (oddsArray.length === 0) missingParts.push("Odds");
}
// 502 caused missing data → do NOT save, retry later
if (hasCriticalError && missingParts.length > 0) {
this.logger.warn(
`[${matchId}] ⛔ SKIPPED SAVE: 502 errors caused missing [${missingParts.join(", ")}]. Will retry for complete data.`,
);
return { success: false, retryable: true };
}
// 4. SAVE
let saved = false; let saved = false;
if (scope === "lineups") { if (scope === "lineups") {
saved = await this.persistenceService.saveLineups( saved = await this.persistenceService.saveLineups(
@@ -941,34 +1023,11 @@ export class FeederService {
*/ */
// ========================================== // ==========================================
const completedMatch = isMatchCompleted({ // No 502 but data genuinely missing → save anyway, log warning
state: headerData?.matchStatus ?? matchSummary.state, if (saved && missingParts.length > 0) {
status: matchSummary.status,
substate: matchSummary.substate,
statusBoxContent: matchSummary.statusBoxContent,
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
});
const missingParts: string[] = [];
if (scope === "all" && completedMatch) {
if (sport === "football" && !stats) missingParts.push("Stats");
if (sport === "football" && participationData.length < 18)
missingParts.push("Lineups");
if (sport === "basketball" && !basketballTeamStats)
missingParts.push("BoxScore");
if (oddsArray.length === 0) missingParts.push("Odds");
}
if (saved && (hasCriticalError || missingParts.length > 0)) {
const reason = hasCriticalError
? "missing data after upstream errors"
: "incomplete completed-match payload";
this.logger.warn( this.logger.warn(
`[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`, `[${matchId}] Saved but data genuinely missing (no 502): [${missingParts.join(", ")}]`,
); );
return { success: false, retryable: true };
} }
return { success: saved, retryable: !saved }; return { success: saved, retryable: !saved };
+12 -1
View File
@@ -1,5 +1,5 @@
import { Controller, Get, Res } from "@nestjs/common"; import { Controller, Get, Res } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger"; import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { Public } from "../../common/decorators"; import { Public } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service"; import { PrismaService } from "../../database/prisma.service";
@@ -52,6 +52,17 @@ export class HealthController {
@Get("live") @Get("live")
@Public() @Public()
@ApiOperation({ summary: "Liveness check" }) @ApiOperation({ summary: "Liveness check" })
@ApiResponse({
status: 200,
description: "System liveness",
schema: {
type: "object",
properties: {
status: { type: "string" },
timestamp: { type: "string" },
},
},
})
liveness(@Res() response: Response) { liveness(@Res() response: Response) {
return response return response
.status(200) .status(200)
+46 -1
View File
@@ -28,7 +28,11 @@ export class LeaguesController {
@Get("countries") @Get("countries")
@Public() @Public()
@ApiOperation({ summary: "Get all countries" }) @ApiOperation({ summary: "Get all countries" })
@ApiResponse({ status: 200, description: "List of countries" }) @ApiResponse({
status: 200,
description: "List of countries",
schema: { type: "array", items: { type: "object" } },
})
async getCountries() { async getCountries() {
return this.leaguesService.findAllCountries(); return this.leaguesService.findAllCountries();
} }
@@ -40,6 +44,11 @@ export class LeaguesController {
@Get("countries/:id") @Get("countries/:id")
@Public() @Public()
@ApiOperation({ summary: "Get country by ID with leagues" }) @ApiOperation({ summary: "Get country by ID with leagues" })
@ApiResponse({
status: 200,
description: "Country details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "Country ID" }) @ApiParam({ name: "id", description: "Country ID" })
async getCountryById(@Param("id") id: string) { async getCountryById(@Param("id") id: string) {
const country = await this.leaguesService.findCountryById(id); const country = await this.leaguesService.findCountryById(id);
@@ -54,6 +63,11 @@ export class LeaguesController {
@Get() @Get()
@Public() @Public()
@ApiOperation({ summary: "Get all leagues" }) @ApiOperation({ summary: "Get all leagues" })
@ApiResponse({
status: 200,
description: "List of leagues",
schema: { type: "array", items: { type: "object" } },
})
@ApiQuery({ @ApiQuery({
name: "sport", name: "sport",
required: false, required: false,
@@ -71,6 +85,11 @@ export class LeaguesController {
@Get("teams/h2h") @Get("teams/h2h")
@Public() @Public()
@ApiOperation({ summary: "Get head-to-head matches between two teams" }) @ApiOperation({ summary: "Get head-to-head matches between two teams" })
@ApiResponse({
status: 200,
description: "Head-to-head matches",
schema: { type: "array", items: { type: "object" } },
})
@ApiQuery({ name: "team1", required: true }) @ApiQuery({ name: "team1", required: true })
@ApiQuery({ name: "team2", required: true }) @ApiQuery({ name: "team2", required: true })
@ApiQuery({ name: "limit", required: false, type: Number }) @ApiQuery({ name: "limit", required: false, type: Number })
@@ -93,6 +112,11 @@ export class LeaguesController {
@Get("teams/search") @Get("teams/search")
@Public() @Public()
@ApiOperation({ summary: "Search teams by name" }) @ApiOperation({ summary: "Search teams by name" })
@ApiResponse({
status: 200,
description: "List of teams matching search",
schema: { type: "array", items: { type: "object" } },
})
@ApiQuery({ name: "q", required: true, description: "Search query" }) @ApiQuery({ name: "q", required: true, description: "Search query" })
@ApiQuery({ @ApiQuery({
name: "sport", name: "sport",
@@ -110,6 +134,11 @@ export class LeaguesController {
@Get("teams/:id") @Get("teams/:id")
@Public() @Public()
@ApiOperation({ summary: "Get team by ID" }) @ApiOperation({ summary: "Get team by ID" })
@ApiResponse({
status: 200,
description: "Team details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "Team ID" }) @ApiParam({ name: "id", description: "Team ID" })
async getTeamById(@Param("id") id: string) { async getTeamById(@Param("id") id: string) {
const team = await this.leaguesService.findTeamById(id); const team = await this.leaguesService.findTeamById(id);
@@ -124,6 +153,17 @@ export class LeaguesController {
@Get("teams/:id/matches") @Get("teams/:id/matches")
@Public() @Public()
@ApiOperation({ summary: "Get team's recent matches (paginated)" }) @ApiOperation({ summary: "Get team's recent matches (paginated)" })
@ApiResponse({
status: 200,
description: "Paginated list of matches",
schema: {
type: "object",
properties: {
data: { type: "array", items: { type: "object" } },
meta: { type: "object" },
},
},
})
@ApiParam({ name: "id", description: "Team ID" }) @ApiParam({ name: "id", description: "Team ID" })
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" }) @ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" })
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" }) @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" })
@@ -149,6 +189,11 @@ export class LeaguesController {
@Get(":id") @Get(":id")
@Public() @Public()
@ApiOperation({ summary: "Get league by ID" }) @ApiOperation({ summary: "Get league by ID" })
@ApiResponse({
status: 200,
description: "League details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "League ID" }) @ApiParam({ name: "id", description: "League ID" })
async getLeagueById(@Param("id") id: string) { async getLeagueById(@Param("id") id: string) {
const league = await this.leaguesService.findLeagueById(id); const league = await this.leaguesService.findLeagueById(id);
+12 -1
View File
@@ -71,7 +71,17 @@ export class MatchesController {
@ApiQuery({ name: "page", required: false, type: Number }) @ApiQuery({ name: "page", required: false, type: Number })
@ApiQuery({ name: "limit", required: false, type: Number }) @ApiQuery({ name: "limit", required: false, type: Number })
@ApiQuery({ name: "sport", required: false, enum: Sport }) @ApiQuery({ name: "sport", required: false, enum: Sport })
@ApiResponse({ status: 200, description: "Paginated list of matches" }) @ApiResponse({
status: 200,
description: "Paginated list of matches",
schema: {
type: "object",
properties: {
data: { type: "array", items: { type: "object" } },
meta: { type: "object" },
},
},
})
async listMatches( async listMatches(
@Query("page") page?: string, @Query("page") page?: string,
@Query("limit") limit?: string, @Query("limit") limit?: string,
@@ -112,6 +122,7 @@ export class MatchesController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "Match details with lineups, stats, odds, events", description: "Match details with lineups, stats, odds, events",
schema: { type: "object" },
}) })
@ApiResponse({ status: 404, description: "Match not found" }) @ApiResponse({ status: 404, description: "Match not found" })
async getMatchDetails(@Param("id") id: string) { async getMatchDetails(@Param("id") id: string) {
@@ -56,6 +56,11 @@ export class PredictionsController {
*/ */
@Get("test/:id") @Get("test/:id")
@ApiOperation({ summary: "Refetch match data and get prediction" }) @ApiOperation({ summary: "Refetch match data and get prediction" })
@ApiResponse({
status: 200,
description: "Prediction details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "Match ID" }) @ApiParam({ name: "id", description: "Match ID" })
async getTestPrediction(@Param("id") id: string) { async getTestPrediction(@Param("id") id: string) {
return this.predictionsService.testPrediction(id); return this.predictionsService.testPrediction(id);
@@ -91,7 +96,12 @@ export class PredictionsController {
@Public() @Public()
@ApiOperation({ summary: "Get prediction for a specific match" }) @ApiOperation({ summary: "Get prediction for a specific match" })
@ApiParam({ name: "matchId", description: "Match ID" }) @ApiParam({ name: "matchId", description: "Match ID" })
@ApiResponse({ status: 200, type: MatchPredictionDto }) @ApiResponse({
status: 200,
description: "Match prediction",
schema: { type: "object" },
type: MatchPredictionDto,
})
@ApiResponse({ status: 404, description: "Match not found" }) @ApiResponse({ status: 404, description: "Match not found" })
async getPrediction( async getPrediction(
@Param("matchId") matchId: string, @Param("matchId") matchId: string,
@@ -145,6 +155,7 @@ export class PredictionsController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "Smart coupon generated successfully", description: "Smart coupon generated successfully",
schema: { type: "object" },
}) })
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> { async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
const coupon = await this.predictionsService.getSmartCoupon( const coupon = await this.predictionsService.getSmartCoupon(
+26 -4
View File
@@ -54,6 +54,7 @@ export class SporTotoController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "Sync result with action (created/updated/unchanged)", description: "Sync result with action (created/updated/unchanged)",
schema: { type: "object" },
}) })
async syncFromApi() { async syncFromApi() {
const result = await this.sporTotoService.syncFromApi(); const result = await this.sporTotoService.syncFromApi();
@@ -82,6 +83,7 @@ export class SporTotoController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "Array of bulletins with matches and results", description: "Array of bulletins with matches and results",
schema: { type: "object" },
}) })
async listBulletins( async listBulletins(
@Query("status") status?: TotoBulletinStatus, @Query("status") status?: TotoBulletinStatus,
@@ -105,6 +107,7 @@ export class SporTotoController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "Bulletin with matches and results", description: "Bulletin with matches and results",
schema: { type: "object" },
}) })
@ApiResponse({ status: 404, description: "Bulletin not found" }) @ApiResponse({ status: 404, description: "Bulletin not found" })
async getBulletin(@Param("id") id: string) { async getBulletin(@Param("id") id: string) {
@@ -123,7 +126,11 @@ export class SporTotoController {
"Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.", "Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.",
}) })
@ApiBody({ type: CreateBulletinDto }) @ApiBody({ type: CreateBulletinDto })
@ApiResponse({ status: 201, description: "Created bulletin with matches" }) @ApiResponse({
status: 201,
description: "Created bulletin with matches",
schema: { type: "object" },
})
@ApiResponse({ @ApiResponse({
status: 409, status: 409,
description: "Bulletin with this gameCycleNo already exists", description: "Bulletin with this gameCycleNo already exists",
@@ -145,7 +152,11 @@ export class SporTotoController {
}) })
@ApiParam({ name: "id", description: "Bulletin UUID" }) @ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiBody({ type: UpdateResultsDto }) @ApiBody({ type: UpdateResultsDto })
@ApiResponse({ status: 200, description: "Updated bulletin with results" }) @ApiResponse({
status: 200,
description: "Updated bulletin with results",
schema: { type: "object" },
})
@ApiResponse({ status: 404, description: "Bulletin not found" }) @ApiResponse({ status: 404, description: "Bulletin not found" })
async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) { async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
const bulletin = await this.sporTotoService.updateResults(id, dto); const bulletin = await this.sporTotoService.updateResults(id, dto);
@@ -162,7 +173,11 @@ export class SporTotoController {
"Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.", "Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.",
}) })
@ApiParam({ name: "id", description: "Bulletin UUID" }) @ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiResponse({ status: 200, description: "Pool distribution and EV stats" }) @ApiResponse({
status: 200,
description: "Pool distribution and EV stats",
schema: { type: "object" },
})
async getBulletinStats(@Param("id") id: string) { async getBulletinStats(@Param("id") id: string) {
const stats = await this.sporTotoService.getBulletinStats(id); const stats = await this.sporTotoService.getBulletinStats(id);
return { success: true, data: stats }; return { success: true, data: stats };
@@ -181,7 +196,11 @@ export class SporTotoController {
type: Number, type: Number,
description: "Number of results (default: 20)", description: "Number of results (default: 20)",
}) })
@ApiResponse({ status: 200, description: "Rollover history with trend data" }) @ApiResponse({
status: 200,
description: "Rollover history with trend data",
schema: { type: "object" },
})
async getRolloverHistory(@Query("limit") limit?: string) { async getRolloverHistory(@Query("limit") limit?: string) {
const history = await this.sporTotoService.getRolloverHistory( const history = await this.sporTotoService.getRolloverHistory(
Number(limit) || 20, Number(limit) || 20,
@@ -204,6 +223,7 @@ export class SporTotoController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "Generated columns with strategy, cost, and column strings", description: "Generated columns with strategy, cost, and column strings",
schema: { type: "object" },
}) })
async generateColumns(@Body() dto: GenerateColumnsDto) { async generateColumns(@Body() dto: GenerateColumnsDto) {
const result = await this.sporTotoService.generateColumns(dto); const result = await this.sporTotoService.generateColumns(dto);
@@ -223,6 +243,7 @@ export class SporTotoController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "Evaluation results with correct counts per column", description: "Evaluation results with correct counts per column",
schema: { type: "object" },
}) })
async evaluateColumns(@Body() dto: EvaluateColumnsDto) { async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
const result = await this.sporTotoService.evaluateColumns( const result = await this.sporTotoService.evaluateColumns(
@@ -248,6 +269,7 @@ export class SporTotoController {
status: 200, status: 200,
description: description:
"Prediction result with per-match analysis, system coupon, and EV report with play recommendation", "Prediction result with per-match analysis, system coupon, and EV report with play recommendation",
schema: { type: "object" },
}) })
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) { async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
this.logger.log( this.logger.log(
+123
View File
@@ -0,0 +1,123 @@
/**
* Repair Feeder - Fix incomplete matches
* Usage: npm run feeder:repair
*
* Finds matches in DB that are missing stats or lineups
* and re-fetches them from the API.
*/
import { NestFactory } from "@nestjs/core";
import { Logger } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";
import { FeederService } from "../modules/feeder/feeder.service";
async function bootstrap() {
process.env.FEEDER_MODE = "historical";
const logger = new Logger("FeederRepair");
logger.log("🔧 Starting feeder repair scan...");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AppModule } = require("../app.module");
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ["log", "error", "warn"],
});
const prisma = app.get(PrismaService);
const feederService = app.get(FeederService);
try {
// Find football matches missing stats (no football_team_stats rows)
const matchesMissingStats = await prisma.$queryRaw<
Array<{ id: string; match_name: string }>
>`
SELECT m.id, m.match_name
FROM matches m
LEFT JOIN football_team_stats fts ON fts.match_id = m.id
WHERE m.sport = 'football'
AND m.state = 'Ended'
AND fts.id IS NULL
ORDER BY m.mst_utc DESC
`;
// Find football matches missing lineups (< 18 participation rows)
const matchesMissingLineups = await prisma.$queryRaw<
Array<{ id: string; match_name: string; cnt: bigint }>
>`
SELECT m.id, m.match_name, COUNT(mpp.id) as cnt
FROM matches m
LEFT JOIN match_player_participation mpp ON mpp.match_id = m.id
WHERE m.sport = 'football'
AND m.state = 'Ended'
GROUP BY m.id, m.match_name
HAVING COUNT(mpp.id) < 18
ORDER BY m.mst_utc DESC
`;
// Combine unique match IDs
const repairSet = new Set<string>();
for (const m of matchesMissingStats) repairSet.add(m.id);
for (const m of matchesMissingLineups) repairSet.add(m.id);
logger.log(
`📊 Found ${repairSet.size} incomplete matches (${matchesMissingStats.length} missing stats, ${matchesMissingLineups.length} missing lineups)`,
);
if (repairSet.size === 0) {
logger.log("✅ No incomplete matches found. Everything is clean!");
await app.close();
process.exit(0);
}
let repaired = 0;
let failed = 0;
const matchIds = Array.from(repairSet);
for (let i = 0; i < matchIds.length; i++) {
const matchId = matchIds[i];
// Rate limiting
if (i > 0 && i % 10 === 0) {
logger.log(
`⏸️ Cooldown after 10 repairs... (${repaired} repaired, ${failed} failed, ${matchIds.length - i} remaining)`,
);
await new Promise((r) => setTimeout(r, 5000));
}
await new Promise((r) => setTimeout(r, 500));
try {
const result = await feederService.refreshMatch(matchId, "all");
if (result.success) {
repaired++;
if (repaired % 25 === 0) {
logger.log(`🔧 Progress: ${repaired}/${matchIds.length} repaired`);
}
} else {
failed++;
logger.warn(
`❌ [${matchId}] Repair failed: ${result.error || "unknown"}`,
);
}
} catch (e: any) {
failed++;
logger.error(`❌ [${matchId}] Repair exception: ${e.message}`);
}
}
logger.log(
`🎉 REPAIR COMPLETE: ${repaired} repaired, ${failed} failed out of ${matchIds.length} total`,
);
} catch (error: any) {
logger.error(`❌ Repair failed: ${error.message}`);
logger.error(error.stack);
process.exit(1);
} finally {
await app.close();
}
process.exit(0);
}
void bootstrap();
+19 -3
View File
@@ -12,7 +12,7 @@ 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_INTERVAL_MS = 60_000; // Check every 1 minute
const WATCHDOG_TIMEOUT_MS = 5 * 60_000; // Kill if no activity for 5 minutes const WATCHDOG_TIMEOUT_MS = 3 * 60_000; // Kill if no activity for 3 minutes
async function bootstrap() { async function bootstrap() {
process.env.FEEDER_MODE = "historical"; process.env.FEEDER_MODE = "historical";
@@ -31,15 +31,31 @@ async function bootstrap() {
const feederService = app.get(FeederService); const feederService = app.get(FeederService);
// ── Watchdog Timer ────────────────────────────────────────── // ── Watchdog Timer ──────────────────────────────────────────
// If the feeder hangs on an API call for 5+ minutes, force-exit // If the feeder hangs on an API call for 3+ minutes, force-kill
// so PM2 can restart and resume from where it left off in DB. // so PM2 can restart and resume from where it left off in DB.
// NOTE: process.exit(1) alone can be blocked by open handles
// (DB connections, HTTP sockets). We use process.kill(SIGKILL)
// as an unconditional fallback.
const watchdog = setInterval(() => { const watchdog = setInterval(() => {
const idleMs = Date.now() - feederService.lastActivityAt; const idleMs = Date.now() - feederService.lastActivityAt;
if (idleMs > WATCHDOG_TIMEOUT_MS) { if (idleMs > WATCHDOG_TIMEOUT_MS) {
logger.error( logger.error(
`🐕 WATCHDOG: No activity for ${Math.round(idleMs / 1000)}s. Force-exiting for PM2 restart...`, `🐕 WATCHDOG: No activity for ${Math.round(idleMs / 1000)}s. Force-killing for PM2 restart...`,
); );
// Try graceful exit first
try {
process.exit(1); process.exit(1);
} catch {
// Ignored fallback below
}
// If process.exit didn't work (blocked by open handles),
// schedule an unconditional SIGKILL after 2 seconds
setTimeout(() => {
logger.error("🐕 WATCHDOG: process.exit blocked. Sending SIGKILL...");
process.kill(process.pid, "SIGKILL");
}, 2_000).unref();
} }
}, WATCHDOG_INTERVAL_MS); }, WATCHDOG_INTERVAL_MS);
BIN
View File
Binary file not shown.