主要改动:
自动排课接口立即返回 202 + jobId 后台服务独立执行,不受浏览器关闭或前端超时影响 进度、成功、失败状态持久化到数据库 服务重启后自动恢复排队中或中断的任务 同一草稿禁止重复提交后台任务 运行期间禁止编辑、发布或删除该课表 排课结果和任务成功状态在同一事务中提交 前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
@@ -6,8 +6,15 @@ namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
{
|
||||
public Task<AutomaticScheduleResult> GenerateAsync(
|
||||
SchedulePlan plan,
|
||||
CancellationToken cancellationToken) =>
|
||||
GenerateAsync(plan, null, true, cancellationToken);
|
||||
|
||||
public async Task<AutomaticScheduleResult> GenerateAsync(
|
||||
SchedulePlan plan,
|
||||
Func<AutomaticScheduleProgress, CancellationToken, Task>? reportProgress,
|
||||
bool saveChanges,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
@@ -50,9 +57,18 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
|
||||
var created = 0;
|
||||
var completedTasks = 0;
|
||||
var processedTasks = 0;
|
||||
var messages = new List<string>();
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, 0, 0, 0),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
var scheduledHours = entries
|
||||
.Where(x => x.TeachingTaskId == task.Id)
|
||||
@@ -61,6 +77,13 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
if (remainingHours == 0)
|
||||
{
|
||||
completedTasks++;
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -74,7 +97,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
desiredBlock,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries);
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is null && desiredBlock > 1)
|
||||
{
|
||||
candidate = FindBestCandidate(
|
||||
@@ -84,7 +108,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
1,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries);
|
||||
entries,
|
||||
cancellationToken);
|
||||
}
|
||||
if (candidate is null) break;
|
||||
|
||||
@@ -103,11 +128,24 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
||||
}
|
||||
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0)
|
||||
if (created > 0 && saveChanges)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return new(created, completedTasks, messages);
|
||||
return new(
|
||||
created,
|
||||
completedTasks,
|
||||
messages,
|
||||
tasks.Count,
|
||||
processedTasks);
|
||||
}
|
||||
|
||||
private static ScheduleEntry? FindBestCandidate(
|
||||
@@ -117,7 +155,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
int periodCount,
|
||||
HashSet<int> activePeriods,
|
||||
IReadOnlyList<Classroom> classrooms,
|
||||
IReadOnlyList<ScheduleEntry> entries)
|
||||
IReadOnlyList<ScheduleEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
||||
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||
@@ -129,8 +168,10 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
||||
foreach (var day in allowedDays)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||
continue;
|
||||
|
||||
@@ -145,7 +186,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
TeachingTaskId = task.Id,
|
||||
TeachingTask = task,
|
||||
ClassroomId = room?.Id,
|
||||
Classroom = room,
|
||||
DayOfWeek = day,
|
||||
StartPeriod = start,
|
||||
PeriodCount = periodCount,
|
||||
@@ -216,4 +256,12 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
public sealed record AutomaticScheduleResult(
|
||||
int CreatedEntries,
|
||||
int CompletedTasks,
|
||||
IReadOnlyList<string> Messages);
|
||||
IReadOnlyList<string> Messages,
|
||||
int TotalTasks = 0,
|
||||
int ProcessedTasks = 0);
|
||||
|
||||
public sealed record AutomaticScheduleProgress(
|
||||
int TotalTasks,
|
||||
int ProcessedTasks,
|
||||
int CreatedEntries,
|
||||
int CompletedTasks);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class AutomaticScheduleJobQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void Enqueue(Guid jobId)
|
||||
{
|
||||
if (!_channel.Writer.TryWrite(jobId))
|
||||
throw new InvalidOperationException("自动排课任务队列当前不可用。");
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AutomaticScheduleJobWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
AutomaticScheduleJobQueue queue,
|
||||
ILogger<AutomaticScheduleJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider
|
||||
.GetRequiredService<AutomaticScheduleJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Unexpected failure while dispatching automatic schedule job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Automatic schedule job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobs = await db.AutomaticScheduleJobs
|
||||
.Where(x =>
|
||||
x.Status == AutomaticScheduleJobStatus.Queued ||
|
||||
x.Status == AutomaticScheduleJobStatus.Running)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
job.Status = AutomaticScheduleJobStatus.Queued;
|
||||
job.ActiveSchedulePlanId = job.SchedulePlanId;
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
}
|
||||
|
||||
if (jobs.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var job in jobs)
|
||||
queue.Enqueue(job.Id);
|
||||
|
||||
if (jobs.Count > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Recovered {JobCount} queued or interrupted automatic schedule jobs.",
|
||||
jobs.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AutomaticScheduleJobProcessor(
|
||||
AppDbContext db,
|
||||
AutomaticScheduleGenerator generator,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<AutomaticScheduleJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.AutomaticScheduleJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is AutomaticScheduleJobStatus.Succeeded
|
||||
or AutomaticScheduleJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var plan = await db.SchedulePlans.FirstOrDefaultAsync(
|
||||
x => x.Id == job.SchedulePlanId,
|
||||
stoppingToken);
|
||||
if (plan is null || plan.Status != SchedulePlanStatus.Draft)
|
||||
throw new InvalidOperationException("排课草稿不存在或已不允许修改。");
|
||||
|
||||
job.Status = AutomaticScheduleJobStatus.Running;
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.TotalTasks = await db.TeachingTasks.CountAsync(
|
||||
x =>
|
||||
x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published,
|
||||
stoppingToken);
|
||||
job.ProcessedTasks = 0;
|
||||
job.CreatedEntries = 0;
|
||||
job.CompletedTasks = 0;
|
||||
job.MessagesJson = null;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
var progressClock = Stopwatch.StartNew();
|
||||
var lastPersistedTaskCount = 0;
|
||||
|
||||
async Task ReportProgress(
|
||||
AutomaticScheduleProgress progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isFinal = progress.ProcessedTasks >= progress.TotalTasks;
|
||||
var hasBatch = progress.ProcessedTasks - lastPersistedTaskCount >= 5;
|
||||
if (!isFinal && !hasBatch && progressClock.ElapsedMilliseconds < 500)
|
||||
return;
|
||||
|
||||
await PersistProgressAsync(jobId, progress, cancellationToken);
|
||||
lastPersistedTaskCount = progress.ProcessedTasks;
|
||||
progressClock.Restart();
|
||||
}
|
||||
|
||||
var result = await generator.GenerateAsync(
|
||||
plan,
|
||||
ReportProgress,
|
||||
false,
|
||||
stoppingToken);
|
||||
job.Status = AutomaticScheduleJobStatus.Succeeded;
|
||||
job.ActiveSchedulePlanId = null;
|
||||
job.TotalTasks = result.TotalTasks;
|
||||
job.ProcessedTasks = result.ProcessedTasks;
|
||||
job.CreatedEntries = result.CreatedEntries;
|
||||
job.CompletedTasks = result.CompletedTasks;
|
||||
job.MessagesJson = JsonSerializer.Serialize(result.Messages);
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
|
||||
job.Id,
|
||||
result.CreatedEntries);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Automatic schedule job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Automatic schedule job {JobId} failed.",
|
||||
jobId);
|
||||
await MarkFailedAsync(jobId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistProgressAsync(
|
||||
Guid jobId,
|
||||
AutomaticScheduleProgress progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var progressDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var updatedAt = DateTime.UtcNow;
|
||||
await progressDb.AutomaticScheduleJobs
|
||||
.Where(x =>
|
||||
x.Id == jobId &&
|
||||
x.Status == AutomaticScheduleJobStatus.Running)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.TotalTasks, progress.TotalTasks)
|
||||
.SetProperty(x => x.ProcessedTasks, progress.ProcessedTasks)
|
||||
.SetProperty(x => x.CreatedEntries, progress.CreatedEntries)
|
||||
.SetProperty(x => x.CompletedTasks, progress.CompletedTasks)
|
||||
.SetProperty(x => x.UpdatedAt, updatedAt),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not persist progress for automatic schedule job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, Exception exception)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.AutomaticScheduleJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
var message = exception.GetBaseException().Message;
|
||||
job.Status = AutomaticScheduleJobStatus.Failed;
|
||||
job.ActiveSchedulePlanId = null;
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user