Enhance GridScheduleGenerator and related components for improved slot handling and cursor management
ci / build-backend (push) Successful in 1m21s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m40s
ci / sonar (push) Successful in 3m46s

Updated the GridScheduleGenerator to support rebuilding future schedules while correctly managing aired positions. Introduced a new AiredPosition record to track the last aired state of slots, ensuring that the cursor rewinds to the correct position during rebuilds. Refactored the BuildInputAsync method to accept aired positions, and modified the LoadAiredPositionsAsync method for accurate retrieval of past entries. Enhanced the SchedulePlanner to utilize shared cursor states between main and background loops, preventing duplicate series plays. Updated documentation to reflect these changes and added integration tests to verify the correct behavior of the new functionality.
This commit is contained in:
Leonid Pershin
2026-07-29 08:57:46 +03:00
parent c67d1f7752
commit fbfc6e677e
11 changed files with 568 additions and 117 deletions
@@ -80,8 +80,14 @@ public sealed class GridScheduleGenerator(
await CleanupAsync(channelId, now, cancellationToken);
// Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение
// проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли.
var aired = new Dictionary<Guid, AiredPosition>();
if (rebuildFuture)
{
await DropFutureAsync(channelId, now, cancellationToken);
aired = await LoadAiredPositionsAsync(channelId, cancellationToken);
}
// Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
// поэтому она укажет на границу неизменяемого прошлого.
@@ -104,6 +110,7 @@ public sealed class GridScheduleGenerator(
template,
startUtc,
horizonEnd,
aired,
cancellationToken
);
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
@@ -166,7 +173,15 @@ public sealed class GridScheduleGenerator(
return null;
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken);
// Предпросмотр ничего не сносит, отматывать нечего: симуляция идёт от текущего состояния.
var input = await BuildInputAsync(
channel,
template,
from,
horizonEnd,
new Dictionary<Guid, AiredPosition>(),
cancellationToken
);
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
var postWarnings = await postChecks.RunAsync(
@@ -183,10 +198,6 @@ public sealed class GridScheduleGenerator(
};
}
/// <summary>
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
/// <summary>
/// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
/// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
@@ -304,11 +315,14 @@ public sealed class GridScheduleGenerator(
}
}
/// <param name="aired">Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при
/// пересборке; пусто на обычном тике и в предпросмотре.</param>
private async Task<PlanningInput> BuildInputAsync(
Channel channel,
ScheduleTemplate template,
DateTimeOffset startUtc,
DateTimeOffset horizonEnd,
IReadOnlyDictionary<Guid, AiredPosition> aired,
CancellationToken cancellationToken
)
{
@@ -386,13 +400,21 @@ public sealed class GridScheduleGenerator(
: await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
var cursor = states.TryGetValue(slot.Id, out var state)
? new PlanningCursor(
state.CurrentElementKind,
state.CurrentElementId,
state.NextUnitIndex
var cursor =
(
aired.TryGetValue(slot.Id, out var position)
? RewoundCursor(position, elements)
: null
)
: null;
?? (
states.TryGetValue(slot.Id, out var state)
? new PlanningCursor(
state.CurrentElementKind,
state.CurrentElementId,
state.NextUnitIndex
)
: null
);
planned.Add(
item,
@@ -581,6 +603,117 @@ public sealed class GridScheduleGenerator(
: null;
}
/// <summary>Последняя запись слота в уцелевшей ленте: элемент и единица, которой он доиграл.</summary>
private sealed record AiredPosition(
GroupElementKind ElementKind,
Guid ElementId,
Guid ShowId,
int UnitIndex
);
/// <summary>
/// Куда каждый слот доиграл к моменту пересборки. Элемент берётся из трейса: в самой записи его
/// нет, а по шоу его не восстановить — одно шоу играет и само по себе, и внутри коллекции.
/// </summary>
private async Task<Dictionary<Guid, AiredPosition>> LoadAiredPositionsAsync(
Guid channelId,
CancellationToken cancellationToken
)
{
var maxima = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.SlotId != null
&& e.ShowId != null
&& e.Kind == ScheduleEntryKind.Program
)
.GroupBy(e => e.SlotId!.Value)
.Select(g => new { SlotId = g.Key, Last = g.Max(e => e.StartsAtUtc) })
.ToListAsync(cancellationToken);
var result = new Dictionary<Guid, AiredPosition>();
if (maxima.Count == 0)
return result;
// Вторым запросом, а не подзапросом на строку: слотов десятки, и «взять запись по (слот,
// старт)» на такой выборке дешевле, чем оконная функция по всей ленте канала.
var starts = maxima.Select(m => m.Last).Distinct().ToList();
var rows = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId && e.SlotId != null && starts.Contains(e.StartsAtUtc)
)
.Select(e => new
{
SlotId = e.SlotId!.Value,
e.StartsAtUtc,
e.ShowId,
e.EpisodeIndex,
e.TraceJson,
})
.ToListAsync(cancellationToken);
var bySlotAndStart = rows.ToLookup(r => (r.SlotId, r.StartsAtUtc));
foreach (var max in maxima)
{
var row = bySlotAndStart[(max.SlotId, max.Last)].FirstOrDefault();
if (row?.ShowId is not { } showId || row.EpisodeIndex is not { } unitIndex)
continue;
var trace = ParseTrace(row.TraceJson);
if (trace?.ElementKind is not { } kind || trace.ElementId is not { } elementId)
continue;
result[max.SlotId] = new AiredPosition(kind, elementId, showId, unitIndex);
}
return result;
}
/// <summary>
/// Курсор, отмотанный к отыгранному. Позиция ищется в развёрнутом элементе, а не берётся из
/// <c>EpisodeIndex</c> напрямую: у коллекции серии нумеруются внутри каждой части, и как индекс
/// в общей последовательности элемента это число не годится.
/// </summary>
private static PlanningCursor? RewoundCursor(
AiredPosition aired,
IReadOnlyList<PlanningElement> elements
)
{
var element = elements.FirstOrDefault(e =>
e.Kind == aired.ElementKind && e.ElementId == aired.ElementId
);
if (element is null)
return null;
for (var i = 0; i < element.Units.Count; i++)
if (
element.Units[i].ShowId == aired.ShowId
&& element.Units[i].UnitIndex == aired.UnitIndex
)
return new PlanningCursor(element.Kind, element.ElementId, i + 1);
// Элемент есть, но той единицы в нём больше нет — серию удалили или ассет перестал быть
// готовым. Отматывать некуда, пусть решает сохранённое состояние.
return null;
}
private static PlanTrace? ParseTrace(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
/// <summary>Отрезок ленты, на который ссылается слот-повтор, в UTC.</summary>
private static PlanningRepeatWindow? BuildRepeatWindow(
Channel channel,