Refactor JunctionFiller and SchedulePlanner to improve parameter handling by introducing JunctionPlacement record for cleaner code and better readability. Update Directory.Build.props to suppress additional warnings related to API boundaries and unused route parameters, ensuring a more focused analysis during builds.
ci / build-backend (push) Successful in 1m10s
ci / build-frontend (push) Successful in 37s
ci / tests (push) Successful in 1m31s
ci / sonar (push) Successful in 3m26s

This commit is contained in:
Leonid Pershin
2026-07-27 00:01:22 +03:00
parent 8699d8ee00
commit 38b6160bd2
3 changed files with 109 additions and 127 deletions
+13 -1
View File
@@ -9,6 +9,18 @@
<AnalysisLevel>latest</AnalysisLevel> <AnalysisLevel>latest</AnalysisLevel>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn> <!--
CA1859 (сменить интерфейс коллекции на конкретный тип ради девиртуализации) — осознанно
выключено: помечаются им границы API (доменные модели планировщика, порты, хелперы тестов),
и «ускорение» там означает изменяемую коллекцию, протекающую через границу. Пути не горячие
(генерация расписания идёт раз в тик, а не в цикле по кадрам). Понадобится — включать точечно
по месту, а не глобально.
ASP0018 (параметр маршрута не используется хендлером) — тоже осознанно: часть шаблонов
объявляет сегменты ради формы URL, а не ради хендлера. Так, превью заставки адресуется
подблоком, но канал и блок остаются в пути, потому что по ним строит ссылки плейлист;
у /{slug}/watch токен подтверждает зрителя, а не подписку на канал.
-->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873;CA1859;ASP0018</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -18,6 +18,19 @@ public sealed class JunctionHistory
_lastPlaced[element.Kind] = moment; _lastPlaced[element.Kind] = moment;
} }
/// <summary>
/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти четыре значения ехали
/// сквозь всю раскладку и вместе с курсором, пределом и накопителями раздували сигнатуры.
/// </summary>
/// <param name="FromShowId">Шоу перед стыком, <paramref name="ToShowId"/> — после; заставке нужна
/// именно пара соседей, её ассет рендерится под неё после сборки ленты.</param>
public sealed record JunctionPlacement(
Guid SlotId,
Guid? FromShowId,
Guid? ToShowId,
bool ElementChanged
);
/// <summary> /// <summary>
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает /// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает
/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь. /// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь.
@@ -27,19 +40,12 @@ public sealed class JunctionHistory
/// </summary> /// </summary>
public static class JunctionFiller public static class JunctionFiller
{ {
/// <summary> /// <summary>Раскладывает стык от <paramref name="cursor"/>. Возвращает курсор после стыка.</summary>
/// Раскладывает стык от <paramref name="cursor"/>. <paramref name="fromShowId"/>/<paramref name="toShowId"/>
/// нужны заставке: её ассет зависит от пары соседей и рендерится после сборки ленты.
/// Возвращает курсор после стыка.
/// </summary>
public static DateTimeOffset Fill( public static DateTimeOffset Fill(
PlanningJunction? junction, PlanningJunction? junction,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset limit, DateTimeOffset limit,
Guid slotId, JunctionPlacement placement,
Guid? fromShowId,
Guid? toShowId,
bool elementChanged,
JunctionHistory history, JunctionHistory history,
List<PlannedItem> items, List<PlannedItem> items,
PlanTrace? trace PlanTrace? trace
@@ -55,7 +61,7 @@ public static class JunctionFiller
foreach (var element in ordered) foreach (var element in ordered)
{ {
if (element.OnlyOnElementChange && !elementChanged) if (element.OnlyOnElementChange && !placement.ElementChanged)
continue; continue;
if (!history.Allows(element, cursor)) if (!history.Allows(element, cursor))
continue; continue;
@@ -63,8 +69,8 @@ public static class JunctionFiller
var placedAt = cursor; var placedAt = cursor;
cursor = cursor =
element.Kind == JunctionElementKind.Bumper element.Kind == JunctionElementKind.Bumper
? PlaceBumper(element, cursor, limit, slotId, fromShowId, toShowId, items, trace) ? PlaceBumper(element, cursor, limit, placement, items, trace)
: PlaceUnits(element, cursor, limit, slotId, items, trace); : PlaceUnits(element, cursor, limit, placement.SlotId, items, trace);
if (cursor > placedAt) if (cursor > placedAt)
history.Record(element, placedAt); history.Record(element, placedAt);
@@ -81,9 +87,7 @@ public static class JunctionFiller
PlanningJunctionElement element, PlanningJunctionElement element,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset limit, DateTimeOffset limit,
Guid slotId, JunctionPlacement placement,
Guid? fromShowId,
Guid? toShowId,
List<PlannedItem> items, List<PlannedItem> items,
PlanTrace? trace PlanTrace? trace
) )
@@ -97,14 +101,14 @@ public static class JunctionFiller
Guid.Empty, Guid.Empty,
cursor, cursor,
end, end,
toShowId, placement.ToShowId,
null, null,
slotId, placement.SlotId,
PlannedItemKind.Bumper, PlannedItemKind.Bumper,
trace, trace,
element.BumperTemplateId, element.BumperTemplateId,
fromShowId, placement.FromShowId,
toShowId placement.ToShowId
) )
); );
return end; return end;
@@ -14,20 +14,35 @@ public static class SchedulePlanner
{ {
private const int IterationBackstop = 100_000; private const int IterationBackstop = 100_000;
/// <summary>
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
///
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
/// границу слота.
/// </summary>
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
{
public PlanningInput Input { get; } = input;
public IRandomSource Random { get; } = random;
public List<PlannedItem> Items { get; } = [];
public List<PlanningCursorUpdate> Cursors { get; } = [];
public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new();
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; }
}
public static PlanningResult Plan(PlanningInput input, IRandomSource random) public static PlanningResult Plan(PlanningInput input, IRandomSource random)
{ {
var items = new List<PlannedItem>(); var run = new PlanningRun(input, random);
var cursors = new List<PlanningCursorUpdate>();
var warnings = new List<PlanningWarning>();
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList(); var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
var cursor = input.StartUtc; var cursor = input.StartUtc;
var iterations = 0; var iterations = 0;
// История врезок общая на прогон: «не чаще раза в полчаса» должно работать и через границу слота.
var junctions = new JunctionHistory();
Guid? previousShowId = null;
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++) for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
{ {
if (iterations++ > IterationBackstop) if (iterations++ > IterationBackstop)
@@ -42,32 +57,20 @@ public static class SchedulePlanner
var nextAnchor = FindNextAnchor(slots, i + 1); var nextAnchor = FindNextAnchor(slots, i + 1);
cursor = OpenSlot(slot, cursor, input, items, warnings, out var trace); cursor = OpenSlot(slot, cursor, run, out var trace);
if (cursor >= input.HorizonEndUtc) if (cursor >= input.HorizonEndUtc)
break; break;
cursor = FillSlot( cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
slot,
cursor,
nextAnchor,
input,
random,
items,
cursors,
warnings,
trace,
junctions,
ref previousShowId
);
} }
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край // Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру. // упрётся в дыру.
if (cursor < input.HorizonEndUtc) if (cursor < input.HorizonEndUtc)
cursor = FillWithFallback(cursor, input.HorizonEndUtc, input, items, null); cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0) if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.FallbackEmpty, PlanningWarningKind.FallbackEmpty,
null, null,
@@ -75,7 +78,7 @@ public static class SchedulePlanner
) )
); );
return new PlanningResult(items, cursors, warnings); return new PlanningResult(run.Items, run.Cursors, run.Warnings);
} }
/// <summary> /// <summary>
@@ -85,9 +88,7 @@ public static class SchedulePlanner
private static DateTimeOffset OpenSlot( private static DateTimeOffset OpenSlot(
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
PlanningInput input, PlanningRun run,
List<PlannedItem> items,
List<PlanningWarning> warnings,
out PlanTrace trace out PlanTrace trace
) )
{ {
@@ -97,7 +98,7 @@ public static class SchedulePlanner
{ {
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно, // До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени. // для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillWithFallback(cursor, slot.TargetStartUtc, input, items, slot.SlotId); cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
} }
else if (slot.SnapToMinutes is { } snap && snap > 0) else if (slot.SnapToMinutes is { } snap && snap > 0)
{ {
@@ -106,7 +107,7 @@ public static class SchedulePlanner
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap)); var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes)) if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
{ {
var afterFill = FillWithFallback(cursor, target, input, items, slot.SlotId); var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
snapped = afterFill > cursor; snapped = afterFill > cursor;
cursor = afterFill; cursor = afterFill;
} }
@@ -114,7 +115,7 @@ public static class SchedulePlanner
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes); var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
if (Math.Abs(drift) > slot.MaxDriftMinutes) if (Math.Abs(drift) > slot.MaxDriftMinutes)
warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.DriftExceeded, PlanningWarningKind.DriftExceeded,
slot.SlotId, slot.SlotId,
@@ -140,17 +141,11 @@ public static class SchedulePlanner
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset? nextAnchor, DateTimeOffset? nextAnchor,
PlanningInput input,
IRandomSource random,
List<PlannedItem> items,
List<PlanningCursorUpdate> cursors,
List<PlanningWarning> warnings,
PlanTrace trace, PlanTrace trace,
JunctionHistory junctions, PlanningRun run
ref Guid? previousShowId
) )
{ {
var limit = Min(input.HorizonEndUtc, nextAnchor); var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
switch (slot.SlotKind) switch (slot.SlotKind)
{ {
@@ -159,30 +154,17 @@ public static class SchedulePlanner
return FillWithFallback( return FillWithFallback(
cursor, cursor,
Min(slot.TargetEndUtc, limit), Min(slot.TargetEndUtc, limit),
input, run,
items,
slot.SlotId, slot.SlotId,
PlannedItemKind.SignOff, PlannedItemKind.SignOff,
trace trace
); );
case SlotKind.Repeat: case SlotKind.Repeat:
return FillRepeat(slot, cursor, limit, input, items, warnings, trace); return FillRepeat(slot, cursor, limit, trace, run);
default: default:
return FillContent( return FillContent(slot, cursor, limit, trace, run);
slot,
cursor,
limit,
input,
random,
items,
cursors,
warnings,
trace,
junctions,
ref previousShowId
);
} }
} }
@@ -190,29 +172,21 @@ public static class SchedulePlanner
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset limit, DateTimeOffset limit,
PlanningInput input, PlanTrace trace,
List<PlannedItem> items, PlanningRun run
List<PlanningWarning> warnings,
PlanTrace trace
) )
{ {
var units = slot.RepeatUnits ?? []; var units = slot.RepeatUnits ?? [];
if (units.Count == 0) if (units.Count == 0)
{ {
warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty, PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId, slot.SlotId,
"В источнике повтора ничего не нашлось — слот закрыт фоном." "В источнике повтора ничего не нашлось — слот закрыт фоном."
) )
); );
return FillWithFallback( return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
cursor,
Min(slot.TargetEndUtc, limit),
input,
items,
slot.SlotId
);
} }
var slotEnd = Min(slot.TargetEndUtc, limit); var slotEnd = Min(slot.TargetEndUtc, limit);
@@ -220,7 +194,7 @@ public static class SchedulePlanner
{ {
if (cursor + unit.Duration > slotEnd) if (cursor + unit.Duration > slotEnd)
break; break;
items.Add(Program(unit, cursor, slot.SlotId, trace)); run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
cursor += unit.Duration; cursor += unit.Duration;
} }
@@ -231,31 +205,19 @@ public static class SchedulePlanner
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset limit, DateTimeOffset limit,
PlanningInput input,
IRandomSource random,
List<PlannedItem> items,
List<PlanningCursorUpdate> cursors,
List<PlanningWarning> warnings,
PlanTrace trace, PlanTrace trace,
JunctionHistory junctions, PlanningRun run
ref Guid? previousShowId
) )
{ {
var pick = ElementSelector.Select(slot, cursor, random); var pick = ElementSelector.Select(slot, cursor, run.Random);
if (pick is null) if (pick is null)
{ {
warnings.Add(NoCandidatesWarning(slot)); run.Warnings.Add(NoCandidatesWarning(slot));
return FillWithFallback( return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
cursor,
Min(slot.TargetEndUtc, limit),
input,
items,
slot.SlotId
);
} }
if (pick.CooldownExhausted) if (pick.CooldownExhausted)
warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.CooldownExhausted, PlanningWarningKind.CooldownExhausted,
slot.SlotId, slot.SlotId,
@@ -283,8 +245,8 @@ public static class SchedulePlanner
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd) && !FitsEntirely(element, unitIndex, cursor, budgetEnd)
) )
{ {
cursors.Add(CursorUpdate(slot, element, unitIndex)); run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
return FillWithFallback(cursor, budgetEnd, input, items, slot.SlotId); return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
} }
while (unitIndex < element.Units.Count && cursor < limit) while (unitIndex < element.Units.Count && cursor < limit)
@@ -297,12 +259,14 @@ public static class SchedulePlanner
slot.JunctionBetween, slot.JunctionBetween,
cursor, cursor,
limit, limit,
new JunctionPlacement(
slot.SlotId, slot.SlotId,
previousShowId, run.PreviousShowId,
unit.ShowId, unit.ShowId,
elementChanged: previousShowId != unit.ShowId, ElementChanged: run.PreviousShowId != unit.ShowId
junctions, ),
items, run.Junctions,
run.Items,
slotTrace slotTrace
); );
@@ -313,12 +277,12 @@ public static class SchedulePlanner
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd)) if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break; break;
items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element))); run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration; cursor += unit.Duration;
accumulated += unit.Duration; accumulated += unit.Duration;
unitIndex++; unitIndex++;
placed++; placed++;
previousShowId = unit.ShowId; run.PreviousShowId = unit.ShowId;
} }
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя. // Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
@@ -327,20 +291,22 @@ public static class SchedulePlanner
slot.JunctionAfter, slot.JunctionAfter,
cursor, cursor,
limit, limit,
new JunctionPlacement(
slot.SlotId, slot.SlotId,
previousShowId, run.PreviousShowId,
null, null,
elementChanged: true, ElementChanged: true
junctions, ),
items, run.Junctions,
run.Items,
slotTrace slotTrace
); );
cursors.Add(CursorUpdate(slot, element, unitIndex)); run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени. // Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd) if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
cursor = FillWithFallback(cursor, budgetEnd, input, items, slot.SlotId); cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
return cursor; return cursor;
} }
@@ -416,14 +382,14 @@ public static class SchedulePlanner
private static DateTimeOffset FillWithFallback( private static DateTimeOffset FillWithFallback(
DateTimeOffset from, DateTimeOffset from,
DateTimeOffset until, DateTimeOffset until,
PlanningInput input, PlanningRun run,
List<PlannedItem> items,
Guid? slotId, Guid? slotId,
PlannedItemKind kind = PlannedItemKind.Fallback, PlannedItemKind kind = PlannedItemKind.Fallback,
PlanTrace? trace = null PlanTrace? trace = null
) )
{ {
if (input.FallbackUnits.Count == 0 || until <= from) var fallback = run.Input.FallbackUnits;
if (fallback.Count == 0 || until <= from)
return from; return from;
var cursor = from; var cursor = from;
@@ -432,13 +398,13 @@ public static class SchedulePlanner
while (cursor < until && guard++ < IterationBackstop) while (cursor < until && guard++ < IterationBackstop)
{ {
var unit = input.FallbackUnits[index % input.FallbackUnits.Count]; var unit = fallback[index % fallback.Count];
index++; index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until) if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
break; break;
items.Add( run.Items.Add(
new PlannedItem( new PlannedItem(
unit.MediaAssetId, unit.MediaAssetId,
cursor, cursor,