Add jobs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Managing.Infrastructure.Databases.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobTypeAndGeneticRequestIdToBacktestJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BacktestJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
BundleRequestId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
JobType = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||
Priority = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||
ConfigJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
StartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ProgressPercentage = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||
AssignedWorkerId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||
LastHeartbeat = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ResultJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "text", nullable: true),
|
||||
RequestId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||
GeneticRequestId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BacktestJobs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BacktestJobs_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_assigned_worker",
|
||||
table: "BacktestJobs",
|
||||
columns: new[] { "AssignedWorkerId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_bundle_request",
|
||||
table: "BacktestJobs",
|
||||
column: "BundleRequestId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_genetic_request",
|
||||
table: "BacktestJobs",
|
||||
column: "GeneticRequestId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_status_jobtype_priority_created",
|
||||
table: "BacktestJobs",
|
||||
columns: new[] { "Status", "JobType", "Priority", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_user_status",
|
||||
table: "BacktestJobs",
|
||||
columns: new[] { "UserId", "Status" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BacktestJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +706,95 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
b.ToTable("Indicators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssignedWorkerId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("BundleRequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ConfigJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("GeneticRequestId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<DateTime?>("LastHeartbeat")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("ProgressPercentage")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("RequestId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BundleRequestId")
|
||||
.HasDatabaseName("idx_bundle_request");
|
||||
|
||||
b.HasIndex("GeneticRequestId")
|
||||
.HasDatabaseName("idx_genetic_request");
|
||||
|
||||
b.HasIndex("AssignedWorkerId", "Status")
|
||||
.HasDatabaseName("idx_assigned_worker");
|
||||
|
||||
b.HasIndex("UserId", "Status")
|
||||
.HasDatabaseName("idx_user_status");
|
||||
|
||||
b.HasIndex("Status", "JobType", "Priority", "CreatedAt")
|
||||
.HasDatabaseName("idx_status_jobtype_priority_created");
|
||||
|
||||
b.ToTable("BacktestJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1494,6 +1583,17 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b =>
|
||||
{
|
||||
b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b =>
|
||||
{
|
||||
b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Managing.Infrastructure.Databases.PostgreSql.Entities;
|
||||
|
||||
[Table("BacktestJobs")]
|
||||
public class JobEntity
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid? BundleRequestId { get; set; }
|
||||
|
||||
[Required]
|
||||
public int UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
public int Status { get; set; } // BacktestJobStatus enum as int
|
||||
|
||||
[Required]
|
||||
public int JobType { get; set; } // JobType enum as int
|
||||
|
||||
[Required]
|
||||
public int Priority { get; set; } = 0;
|
||||
|
||||
[Required]
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string ConfigJson { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public DateTime StartDate { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime EndDate { get; set; }
|
||||
|
||||
[Required]
|
||||
public int ProgressPercentage { get; set; } = 0;
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? AssignedWorkerId { get; set; }
|
||||
|
||||
public DateTime? LastHeartbeat { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string? ResultJson { get; set; }
|
||||
|
||||
[Column(TypeName = "text")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? GeneticRequestId { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public UserEntity? User { get; set; }
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public class ManagingDbContext : DbContext
|
||||
public DbSet<GeneticRequestEntity> GeneticRequests { get; set; }
|
||||
public DbSet<BacktestEntity> Backtests { get; set; }
|
||||
public DbSet<BundleBacktestRequestEntity> BundleBacktestRequests { get; set; }
|
||||
public DbSet<JobEntity> Jobs { get; set; }
|
||||
|
||||
// Trading entities
|
||||
public DbSet<ScenarioEntity> Scenarios { get; set; }
|
||||
@@ -231,6 +232,45 @@ public class ManagingDbContext : DbContext
|
||||
entity.HasIndex(e => new { e.UserId, e.Name, e.Version });
|
||||
});
|
||||
|
||||
// Configure BacktestJob entity
|
||||
modelBuilder.Entity<JobEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).ValueGeneratedNever(); // GUIDs are generated by application
|
||||
entity.Property(e => e.UserId).IsRequired();
|
||||
entity.Property(e => e.Status).IsRequired();
|
||||
entity.Property(e => e.JobType).IsRequired().HasDefaultValue(0); // 0 = Backtest
|
||||
entity.Property(e => e.Priority).IsRequired().HasDefaultValue(0);
|
||||
entity.Property(e => e.ConfigJson).IsRequired().HasColumnType("jsonb");
|
||||
entity.Property(e => e.StartDate).IsRequired();
|
||||
entity.Property(e => e.EndDate).IsRequired();
|
||||
entity.Property(e => e.ProgressPercentage).IsRequired().HasDefaultValue(0);
|
||||
entity.Property(e => e.AssignedWorkerId).HasMaxLength(255);
|
||||
entity.Property(e => e.ResultJson).HasColumnType("jsonb");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnType("text");
|
||||
entity.Property(e => e.RequestId).HasMaxLength(255);
|
||||
entity.Property(e => e.GeneticRequestId).HasMaxLength(255);
|
||||
entity.Property(e => e.CreatedAt).IsRequired();
|
||||
|
||||
// Indexes for efficient job claiming and queries
|
||||
entity.HasIndex(e => new { e.Status, e.JobType, e.Priority, e.CreatedAt })
|
||||
.HasDatabaseName("idx_status_jobtype_priority_created");
|
||||
entity.HasIndex(e => e.BundleRequestId)
|
||||
.HasDatabaseName("idx_bundle_request");
|
||||
entity.HasIndex(e => new { e.AssignedWorkerId, e.Status })
|
||||
.HasDatabaseName("idx_assigned_worker");
|
||||
entity.HasIndex(e => new { e.UserId, e.Status })
|
||||
.HasDatabaseName("idx_user_status");
|
||||
entity.HasIndex(e => e.GeneticRequestId)
|
||||
.HasDatabaseName("idx_genetic_request");
|
||||
|
||||
// Configure relationship with User
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// Configure Scenario entity
|
||||
modelBuilder.Entity<ScenarioEntity>(entity =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Infrastructure.Databases.PostgreSql.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Infrastructure.Databases.PostgreSql;
|
||||
|
||||
public class PostgreSqlJobRepository : IBacktestJobRepository
|
||||
{
|
||||
private readonly ManagingDbContext _context;
|
||||
private readonly ILogger<PostgreSqlJobRepository> _logger;
|
||||
|
||||
public PostgreSqlJobRepository(
|
||||
ManagingDbContext context,
|
||||
ILogger<PostgreSqlJobRepository> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BacktestJob> CreateAsync(BacktestJob job)
|
||||
{
|
||||
var entity = MapToEntity(job);
|
||||
_context.Jobs.Add(entity);
|
||||
await _context.SaveChangesAsync();
|
||||
return MapToDomain(entity);
|
||||
}
|
||||
|
||||
public async Task<BacktestJob?> ClaimNextJobAsync(string workerId, JobType? jobType = null)
|
||||
{
|
||||
// Use execution strategy to support retry with transactions
|
||||
// FOR UPDATE SKIP LOCKED ensures only one worker can claim a specific job
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Build SQL query with optional job type filter
|
||||
var sql = @"
|
||||
SELECT * FROM ""BacktestJobs""
|
||||
WHERE ""Status"" = {0}";
|
||||
|
||||
var parameters = new List<object> { (int)BacktestJobStatus.Pending };
|
||||
|
||||
if (jobType.HasValue)
|
||||
{
|
||||
sql += @" AND ""JobType"" = {1}";
|
||||
parameters.Add((int)jobType.Value);
|
||||
}
|
||||
|
||||
sql += @"
|
||||
ORDER BY ""Priority"" DESC, ""CreatedAt"" ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED";
|
||||
|
||||
// Use raw SQL with FromSqlRaw to get the next job with row-level locking
|
||||
var job = await _context.Jobs
|
||||
.FromSqlRaw(sql, parameters.ToArray())
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (job == null)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update the job status atomically
|
||||
job.Status = (int)BacktestJobStatus.Running;
|
||||
job.AssignedWorkerId = workerId;
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
job.LastHeartbeat = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return MapToDomain(job);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
_logger.LogError(ex, "Error claiming job for worker {WorkerId}", workerId);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(BacktestJob job)
|
||||
{
|
||||
// Use AsTracking() to enable change tracking since DbContext uses NoTracking by default
|
||||
var entity = await _context.Jobs
|
||||
.AsTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == job.Id);
|
||||
|
||||
if (entity == null)
|
||||
{
|
||||
_logger.LogWarning("Job {JobId} not found for update", job.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update entity properties
|
||||
entity.Status = (int)job.Status;
|
||||
entity.JobType = (int)job.JobType;
|
||||
entity.ProgressPercentage = job.ProgressPercentage;
|
||||
entity.AssignedWorkerId = job.AssignedWorkerId;
|
||||
entity.LastHeartbeat = job.LastHeartbeat;
|
||||
entity.StartedAt = job.StartedAt;
|
||||
entity.CompletedAt = job.CompletedAt;
|
||||
entity.ResultJson = job.ResultJson;
|
||||
entity.ErrorMessage = job.ErrorMessage;
|
||||
entity.RequestId = job.RequestId;
|
||||
entity.GeneticRequestId = job.GeneticRequestId;
|
||||
entity.Priority = job.Priority;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BacktestJob>> GetByBundleRequestIdAsync(Guid bundleRequestId)
|
||||
{
|
||||
var entities = await _context.Jobs
|
||||
.Where(j => j.BundleRequestId == bundleRequestId)
|
||||
.ToListAsync();
|
||||
|
||||
return entities.Select(MapToDomain);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BacktestJob>> GetByUserIdAsync(int userId)
|
||||
{
|
||||
var entities = await _context.Jobs
|
||||
.Where(j => j.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
return entities.Select(MapToDomain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all running jobs assigned to a specific worker
|
||||
/// </summary>
|
||||
public async Task<IEnumerable<BacktestJob>> GetRunningJobsByWorkerIdAsync(string workerId)
|
||||
{
|
||||
var entities = await _context.Jobs
|
||||
.Where(j => j.AssignedWorkerId == workerId && j.Status == (int)BacktestJobStatus.Running)
|
||||
.ToListAsync();
|
||||
|
||||
return entities.Select(MapToDomain);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BacktestJob>> GetByGeneticRequestIdAsync(string geneticRequestId)
|
||||
{
|
||||
var entities = await _context.Jobs
|
||||
.Where(j => j.GeneticRequestId == geneticRequestId)
|
||||
.ToListAsync();
|
||||
|
||||
return entities.Select(MapToDomain);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BacktestJob> Jobs, int TotalCount)> GetPaginatedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string sortBy = "CreatedAt",
|
||||
string sortOrder = "desc",
|
||||
BacktestJobStatus? status = null,
|
||||
JobType? jobType = null,
|
||||
int? userId = null,
|
||||
string? workerId = null,
|
||||
Guid? bundleRequestId = null)
|
||||
{
|
||||
var query = _context.Jobs.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(j => j.Status == (int)status.Value);
|
||||
}
|
||||
|
||||
if (jobType.HasValue)
|
||||
{
|
||||
query = query.Where(j => j.JobType == (int)jobType.Value);
|
||||
}
|
||||
|
||||
if (userId.HasValue)
|
||||
{
|
||||
query = query.Where(j => j.UserId == userId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(workerId))
|
||||
{
|
||||
query = query.Where(j => j.AssignedWorkerId == workerId);
|
||||
}
|
||||
|
||||
if (bundleRequestId.HasValue)
|
||||
{
|
||||
query = query.Where(j => j.BundleRequestId == bundleRequestId.Value);
|
||||
}
|
||||
|
||||
// Get total count before pagination
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Apply sorting
|
||||
query = sortBy.ToLower() switch
|
||||
{
|
||||
"createdat" => sortOrder.ToLower() == "asc"
|
||||
? query.OrderBy(j => j.CreatedAt)
|
||||
: query.OrderByDescending(j => j.CreatedAt),
|
||||
"startedat" => sortOrder.ToLower() == "asc"
|
||||
? query.OrderBy(j => j.StartedAt)
|
||||
: query.OrderByDescending(j => j.StartedAt),
|
||||
"completedat" => sortOrder.ToLower() == "asc"
|
||||
? query.OrderBy(j => j.CompletedAt)
|
||||
: query.OrderByDescending(j => j.CompletedAt),
|
||||
"priority" => sortOrder.ToLower() == "asc"
|
||||
? query.OrderBy(j => j.Priority)
|
||||
: query.OrderByDescending(j => j.Priority),
|
||||
"status" => sortOrder.ToLower() == "asc"
|
||||
? query.OrderBy(j => j.Status)
|
||||
: query.OrderByDescending(j => j.Status),
|
||||
"jobtype" => sortOrder.ToLower() == "asc"
|
||||
? query.OrderBy(j => j.JobType)
|
||||
: query.OrderByDescending(j => j.JobType),
|
||||
_ => query.OrderByDescending(j => j.CreatedAt) // Default sort
|
||||
};
|
||||
|
||||
// Apply pagination
|
||||
var entities = await query
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var jobs = entities.Select(MapToDomain);
|
||||
|
||||
return (jobs, totalCount);
|
||||
}
|
||||
|
||||
public async Task<BacktestJob?> GetByIdAsync(Guid jobId)
|
||||
{
|
||||
var entity = await _context.Jobs
|
||||
.FirstOrDefaultAsync(j => j.Id == jobId);
|
||||
|
||||
return entity != null ? MapToDomain(entity) : null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BacktestJob>> GetStaleJobsAsync(int timeoutMinutes = 5)
|
||||
{
|
||||
var timeoutThreshold = DateTime.UtcNow.AddMinutes(-timeoutMinutes);
|
||||
|
||||
var entities = await _context.Jobs
|
||||
.Where(j => j.Status == (int)BacktestJobStatus.Running &&
|
||||
(j.LastHeartbeat == null || j.LastHeartbeat < timeoutThreshold))
|
||||
.ToListAsync();
|
||||
|
||||
return entities.Select(MapToDomain);
|
||||
}
|
||||
|
||||
public async Task<int> ResetStaleJobsAsync(int timeoutMinutes = 5)
|
||||
{
|
||||
var timeoutThreshold = DateTime.UtcNow.AddMinutes(-timeoutMinutes);
|
||||
|
||||
// Use AsTracking() to enable change tracking since DbContext uses NoTracking by default
|
||||
var staleJobs = await _context.Jobs
|
||||
.AsTracking()
|
||||
.Where(j => j.Status == (int)BacktestJobStatus.Running &&
|
||||
(j.LastHeartbeat == null || j.LastHeartbeat < timeoutThreshold))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var job in staleJobs)
|
||||
{
|
||||
job.Status = (int)BacktestJobStatus.Pending;
|
||||
job.AssignedWorkerId = null;
|
||||
job.LastHeartbeat = null;
|
||||
}
|
||||
|
||||
var count = staleJobs.Count;
|
||||
if (count > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Reset {Count} stale jobs back to Pending status", count);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<JobSummary> GetSummaryAsync()
|
||||
{
|
||||
// Use ADO.NET directly for aggregation queries to avoid EF Core mapping issues
|
||||
var connection = _context.Database.GetDbConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var statusCounts = new List<StatusCountResult>();
|
||||
var jobTypeCounts = new List<JobTypeCountResult>();
|
||||
var statusTypeCounts = new List<StatusTypeCountResult>();
|
||||
var totalJobs = 0;
|
||||
|
||||
// Query 1: Status summary
|
||||
var statusSummarySql = @"
|
||||
SELECT ""Status"", COUNT(*) as Count
|
||||
FROM ""BacktestJobs""
|
||||
GROUP BY ""Status""
|
||||
ORDER BY ""Status""";
|
||||
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = statusSummarySql;
|
||||
using (var reader = await command.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
statusCounts.Add(new StatusCountResult
|
||||
{
|
||||
Status = reader.GetInt32(0),
|
||||
Count = reader.GetInt32(1)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query 2: Job type summary
|
||||
var jobTypeSummarySql = @"
|
||||
SELECT ""JobType"", COUNT(*) as Count
|
||||
FROM ""BacktestJobs""
|
||||
GROUP BY ""JobType""
|
||||
ORDER BY ""JobType""";
|
||||
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = jobTypeSummarySql;
|
||||
using (var reader = await command.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
jobTypeCounts.Add(new JobTypeCountResult
|
||||
{
|
||||
JobType = reader.GetInt32(0),
|
||||
Count = reader.GetInt32(1)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query 3: Status + Job type summary
|
||||
var statusTypeSummarySql = @"
|
||||
SELECT ""Status"", ""JobType"", COUNT(*) as Count
|
||||
FROM ""BacktestJobs""
|
||||
GROUP BY ""Status"", ""JobType""
|
||||
ORDER BY ""Status"", ""JobType""";
|
||||
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = statusTypeSummarySql;
|
||||
using (var reader = await command.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
statusTypeCounts.Add(new StatusTypeCountResult
|
||||
{
|
||||
Status = reader.GetInt32(0),
|
||||
JobType = reader.GetInt32(1),
|
||||
Count = reader.GetInt32(2)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query 4: Total count
|
||||
var totalCountSql = @"
|
||||
SELECT COUNT(*) as Count
|
||||
FROM ""BacktestJobs""";
|
||||
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = totalCountSql;
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
totalJobs = result != null ? Convert.ToInt32(result) : 0;
|
||||
}
|
||||
|
||||
return new JobSummary
|
||||
{
|
||||
StatusCounts = statusCounts.Select(s => new JobStatusCount
|
||||
{
|
||||
Status = (BacktestJobStatus)s.Status,
|
||||
Count = s.Count
|
||||
}).ToList(),
|
||||
JobTypeCounts = jobTypeCounts.Select(j => new JobTypeCount
|
||||
{
|
||||
JobType = (JobType)j.JobType,
|
||||
Count = j.Count
|
||||
}).ToList(),
|
||||
StatusTypeCounts = statusTypeCounts.Select(st => new JobStatusTypeCount
|
||||
{
|
||||
Status = (BacktestJobStatus)st.Status,
|
||||
JobType = (JobType)st.JobType,
|
||||
Count = st.Count
|
||||
}).ToList(),
|
||||
TotalJobs = totalJobs
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper classes for raw SQL query results
|
||||
private class StatusCountResult
|
||||
{
|
||||
public int Status { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private class JobTypeCountResult
|
||||
{
|
||||
public int JobType { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private class StatusTypeCountResult
|
||||
{
|
||||
public int Status { get; set; }
|
||||
public int JobType { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private class TotalCountResult
|
||||
{
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private static JobEntity MapToEntity(BacktestJob job)
|
||||
{
|
||||
return new JobEntity
|
||||
{
|
||||
Id = job.Id,
|
||||
BundleRequestId = job.BundleRequestId,
|
||||
UserId = job.UserId,
|
||||
Status = (int)job.Status,
|
||||
JobType = (int)job.JobType,
|
||||
Priority = job.Priority,
|
||||
ConfigJson = job.ConfigJson,
|
||||
StartDate = job.StartDate,
|
||||
EndDate = job.EndDate,
|
||||
ProgressPercentage = job.ProgressPercentage,
|
||||
AssignedWorkerId = job.AssignedWorkerId,
|
||||
LastHeartbeat = job.LastHeartbeat,
|
||||
CreatedAt = job.CreatedAt,
|
||||
StartedAt = job.StartedAt,
|
||||
CompletedAt = job.CompletedAt,
|
||||
ResultJson = job.ResultJson,
|
||||
ErrorMessage = job.ErrorMessage,
|
||||
RequestId = job.RequestId,
|
||||
GeneticRequestId = job.GeneticRequestId
|
||||
};
|
||||
}
|
||||
|
||||
private static BacktestJob MapToDomain(JobEntity entity)
|
||||
{
|
||||
return new BacktestJob
|
||||
{
|
||||
Id = entity.Id,
|
||||
BundleRequestId = entity.BundleRequestId,
|
||||
UserId = entity.UserId,
|
||||
Status = (BacktestJobStatus)entity.Status,
|
||||
JobType = (JobType)entity.JobType,
|
||||
Priority = entity.Priority,
|
||||
ConfigJson = entity.ConfigJson,
|
||||
StartDate = entity.StartDate,
|
||||
EndDate = entity.EndDate,
|
||||
ProgressPercentage = entity.ProgressPercentage,
|
||||
AssignedWorkerId = entity.AssignedWorkerId,
|
||||
LastHeartbeat = entity.LastHeartbeat,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
StartedAt = entity.StartedAt,
|
||||
CompletedAt = entity.CompletedAt,
|
||||
ResultJson = entity.ResultJson,
|
||||
ErrorMessage = entity.ErrorMessage,
|
||||
RequestId = entity.RequestId,
|
||||
GeneticRequestId = entity.GeneticRequestId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user