Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
22ac28a
docs: add tile merge report design spec
shimoncohen Sep 15, 2026
7b01558
docs: mark report error-handling as open PR question; link MAPCO-11688
shimoncohen Sep 15, 2026
c043385
docs: add tile merge report implementation plan
shimoncohen Sep 15, 2026
5e2145a
docs: note existing TaskExecutorTest call sites to update in plan Task 6
shimoncohen Sep 15, 2026
123ccf8
feat: add MergeStats to describe merge tile provenance
shimoncohen Sep 15, 2026
8fc1789
feat: expose target/source provenance from MergeTiles via MergeStats
shimoncohen Sep 15, 2026
292f6d4
feat: add MergeReport accumulator with counts, percentages and added-…
shimoncohen Sep 15, 2026
c287eec
docs: plan Task 3 emits camelCase JSON to match spec artifact shape
shimoncohen Sep 15, 2026
3ac051d
fix: serialize MergeReport JSON as camelCase
shimoncohen Sep 15, 2026
4ff3b2f
feat: add ReportWriter with FS and S3 sinks for merge report artifact
shimoncohen Sep 15, 2026
7c3219b
feat: add ReportOutputPath to job AdditionalParams
shimoncohen Sep 15, 2026
daba0e7
feat: classify added/merged/replaced tiles and emit merge report in T…
shimoncohen Sep 15, 2026
c6aaecc
feat: pass job ReportOutputPath from TaskRunner to TaskExecutor
shimoncohen Sep 15, 2026
25b1c19
build: register ReportWriter and add REPORT sink config
shimoncohen Sep 15, 2026
f47ed93
fix: resolve S3 client lazily in ReportWriter and add skipped-path tests
shimoncohen Sep 15, 2026
fea939a
chore: drop superpowers workflow docs from PR
shimoncohen Sep 15, 2026
f76a040
refactor: encapsulate tile classification in MergeReport.RecordOutcome
shimoncohen Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions MergerLogic/ImageProcessing/ITileMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ namespace MergerLogic.ImageProcessing
public interface ITileMerger
{
Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy, bool uploadOnly = false);

Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy, out MergeStats stats, bool uploadOnly = false);
}
}
18 changes: 18 additions & 0 deletions MergerLogic/ImageProcessing/MergeStats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace MergerLogic.ImageProcessing
{
/// <summary>
/// Describes which inputs contributed to a merged tile, used to classify the
/// write as added / merged / replaced. TargetUsed is false in upload-only mode.
/// </summary>
public readonly struct MergeStats
{
public bool TargetUsed { get; }
public bool AnySourceUsed { get; }

public MergeStats(bool targetUsed, bool anySourceUsed)
{
this.TargetUsed = targetUsed;
this.AnySourceUsed = anySourceUsed;
}
}
}
33 changes: 31 additions & 2 deletions MergerLogic/ImageProcessing/TileMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ public TileMerger(ITileScaler tileScaler, ILogger<TileMerger> logger)

public Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy, bool uploadOnly = false)
{
return this.MergeTiles(tiles, targetCoords, strategy, out _, uploadOnly);
}

public Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy,
out MergeStats stats, bool uploadOnly = false)
{
bool targetUsed = false;
bool anySourceUsed = false;

if(uploadOnly) {
this._logger.LogDebug($"[{MethodBase.GetCurrentMethod()?.Name}] Configured to upload only mode");
// Ignore target if in upload only mode
Expand All @@ -30,18 +39,21 @@ public TileMerger(ITileScaler tileScaler, ILogger<TileMerger> logger)
this._logger.LogDebug($"[{MethodBase.GetCurrentMethod()?.Name}] Only one source was found, using raw image");
Tile? rawTile = tiles[0]();
rawTile?.ConvertToFormat(strategy.ApplyStrategy(rawTile.Format));
stats = new MergeStats(false, rawTile != null);
return rawTile;
}
}

var images = this.GetImageList(tiles, targetCoords, uploadOnly);
bool hasTarget = !uploadOnly && tiles.Count > 0;
var images = this.GetImageList(tiles, targetCoords, uploadOnly, hasTarget, out targetUsed, out anySourceUsed);
IMagickImage<byte> image;

switch (images.Count)
{
case 0:
// There are no images
this._logger.LogDebug($"[{MethodBase.GetCurrentMethod()?.Name}] No images where found return null");
stats = new MergeStats(targetUsed, anySourceUsed);
return null;
case 1:
image = images[0];
Expand Down Expand Up @@ -91,14 +103,18 @@ public TileMerger(ITileScaler tileScaler, ILogger<TileMerger> logger)
tile = new Tile(targetCoords, image);
}
tile.ConvertToFormat(strategy.ApplyStrategy(tile.Format));
stats = new MergeStats(targetUsed, anySourceUsed);
return tile;
}

private List<MagickImage> GetImageList(List<CorrespondingTileBuilder> tiles, Coord targetCoords, bool uploadOnly)
private List<MagickImage> GetImageList(List<CorrespondingTileBuilder> tiles, Coord targetCoords, bool uploadOnly,
bool hasTarget, out bool targetUsed, out bool anySourceUsed)
{
var images = new List<MagickImage>();
int i = tiles.Count - 1;
Tile? tile = null;
targetUsed = false;
anySourceUsed = false;

bool hasAlpha = false;
try
Expand All @@ -118,7 +134,20 @@ private List<MagickImage> GetImageList(List<CorrespondingTileBuilder> tiles, Coo
continue;
}

int before = images.Count;
this.AddTileToImageList(targetCoords, tile, images, out hasAlpha);
if (images.Count > before)
{
if (hasTarget && i == 0)
{
targetUsed = true;
}
else
{
anySourceUsed = true;
}
}

if (!hasAlpha)
{
return images;
Expand Down
59 changes: 59 additions & 0 deletions MergerLogicUnitTests/ImageProcessing/TileMergerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,65 @@ public void MergeTiles(Tile[] tiles, Coord targetCoord, TileFormatStrategy strat
CollectionAssert.AreEqual(expectedTileBytes, result.GetImageBytes());
}

[TestMethod]
[TestCategory("MergeTiles")]
public void MergeTilesStatsBlendedTargetAndSource()
{
var targetCoord = new Coord(15, 0, 0);
// target (index 0) is transparent, source (last) is transparent -> both enter the stack
var tiles = new[]
{
new Tile(targetCoord, File.ReadAllBytes("2.png")),
new Tile(targetCoord, File.ReadAllBytes("1.png"))
};
var tileBuilders = tiles.Select<Tile, CorrespondingTileBuilder>(tile => () => tile).ToList();

var result = this._testTileMerger.MergeTiles(tileBuilders, targetCoord, new TileFormatStrategy(TileFormat.Png), out var stats);

Assert.IsNotNull(result);
Assert.IsTrue(stats.TargetUsed);
Assert.IsTrue(stats.AnySourceUsed);
}

[TestMethod]
[TestCategory("MergeTiles")]
public void MergeTilesStatsOpaqueSourceOverTarget()
{
var targetCoord = new Coord(15, 0, 0);
// opaque source (last) short-circuits before the target (index 0) is reached
var tiles = new[]
{
new Tile(targetCoord, File.ReadAllBytes("1.png")),
new Tile(targetCoord, File.ReadAllBytes("3.jpeg"))
};
var tileBuilders = tiles.Select<Tile, CorrespondingTileBuilder>(tile => () => tile).ToList();

var result = this._testTileMerger.MergeTiles(tileBuilders, targetCoord, new TileFormatStrategy(TileFormat.Jpeg), out var stats);

Assert.IsNotNull(result);
Assert.IsFalse(stats.TargetUsed);
Assert.IsTrue(stats.AnySourceUsed);
}

[TestMethod]
[TestCategory("MergeTiles")]
public void MergeTilesStatsUploadOnly()
{
var targetCoord = new Coord(15, 0, 0);
var tiles = new[]
{
new Tile(targetCoord, File.ReadAllBytes("2.png")),
new Tile(targetCoord, File.ReadAllBytes("1.png"))
};
var tileBuilders = tiles.Select<Tile, CorrespondingTileBuilder>(tile => () => tile).ToList();

var result = this._testTileMerger.MergeTiles(tileBuilders, targetCoord, new TileFormatStrategy(TileFormat.Jpeg), out var stats, uploadOnly: true);

Assert.IsNotNull(result);
Assert.IsFalse(stats.TargetUsed);
Assert.IsTrue(stats.AnySourceUsed);
}

#endregion
}
}
4 changes: 3 additions & 1 deletion MergerService/Models/Jobs/JobParamersAdditiomalParams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ namespace MergerService.Models.Jobs
public class AdditionalParams
{
[JsonInclude] public string? JobTrackerServiceURL { get; }
[JsonInclude] public string? ReportOutputPath { get; }

[System.Text.Json.Serialization.JsonIgnore]
private JsonSerializerSettings _jsonSerializerSettings;

public AdditionalParams(string jobTrackerServiceURL)
public AdditionalParams(string jobTrackerServiceURL, string? reportOutputPath = null)
{
this.JobTrackerServiceURL = jobTrackerServiceURL;
this.ReportOutputPath = reportOutputPath;

this._jsonSerializerSettings = new JsonSerializerSettings();
this._jsonSerializerSettings.Converters.Add(new StringEnumConverter());
Expand Down
119 changes: 119 additions & 0 deletions MergerService/Models/Reports/MergeReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using MergerLogic.DataTypes;
using MergerLogic.ImageProcessing;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace MergerService.Models.Reports
{
public class MergeReport
{
public int Version => 1;
public string JobId { get; }
public string TaskId { get; }
public string TaskType { get; }
public string TargetFormat { get; }
public bool IsNewTarget { get; }

public DateTime StartTime { get; private set; }
public DateTime EndTime { get; private set; }
public double DurationSeconds { get; private set; }

public int Added { get; private set; }
public int Merged { get; private set; }
public int Replaced { get; private set; }
public int Skipped { get; private set; }
public int Total => this.Added + this.Merged + this.Replaced + this.Skipped;

public double AddedPercentage { get; private set; }
public double MergedPercentage { get; private set; }
public double ReplacedPercentage { get; private set; }
public double SkippedPercentage { get; private set; }

[JsonProperty("addedTiles")]
public List<Coord> AddedTiles { get; } = new List<Coord>();

public MergeReport(string jobId, string taskId, string taskType, string targetFormat, bool isNewTarget)
{
this.JobId = jobId;
this.TaskId = taskId;
this.TaskType = taskType;
this.TargetFormat = targetFormat;
this.IsNewTarget = isNewTarget;
}

// Classifies a single merged tile into added / merged / replaced / skipped.
// added = tile didn't exist in target before the merge; merged = existed and the
// target was blended with source data; replaced = existed and an opaque source
// covered it; skipped = no tile produced or no source data contributed.
public void RecordOutcome(Coord coord, bool existedBefore, bool tileProduced, MergeStats stats)
{
if (!tileProduced || !stats.AnySourceUsed)
{
this.RecordSkipped();
return;
}

if (!existedBefore)
{
this.RecordAdded(coord);
return;
}

if (stats.TargetUsed)
{
this.RecordMerged();
return;
}

this.RecordReplaced();
}

private void RecordAdded(Coord coord)
{
this.Added++;
// store a copy so a later in-place coord mutation can't corrupt the list
this.AddedTiles.Add(new Coord(coord.Z, coord.X, coord.Y));
}

private void RecordMerged() => this.Merged++;
private void RecordReplaced() => this.Replaced++;
private void RecordSkipped() => this.Skipped++;

public void Finalize(DateTime startTime, DateTime endTime)
{
this.StartTime = startTime;
this.EndTime = endTime;
this.DurationSeconds = (endTime - startTime).TotalSeconds;

int total = this.Total;
if (total > 0)
{
this.AddedPercentage = 100.0 * this.Added / total;
this.MergedPercentage = 100.0 * this.Merged / total;
this.ReplacedPercentage = 100.0 * this.Replaced / total;
this.SkippedPercentage = 100.0 * this.Skipped / total;
}
}

public string ToJson() => JsonConvert.SerializeObject(this, Formatting.None,
new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() });

// Summary for the structured log line: counts + percentages, WITHOUT the added-tile list.
public string ToLogString()
{
var summary = new
{
version = this.Version,
jobId = this.JobId,
taskId = this.TaskId,
taskType = this.TaskType,
targetFormat = this.TargetFormat,
isNewTarget = this.IsNewTarget,
durationSeconds = this.DurationSeconds,
counts = new { added = this.Added, merged = this.Merged, replaced = this.Replaced, skipped = this.Skipped, total = this.Total },
percentages = new { added = this.AddedPercentage, merged = this.MergedPercentage, replaced = this.ReplacedPercentage, skipped = this.SkippedPercentage }
};
return JsonConvert.SerializeObject(summary, Formatting.None);
}
}
}
1 change: 1 addition & 0 deletions MergerService/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
builder.Services.AddSingleton<IJobUtils, JobUtils>();
builder.Services.AddSingleton<ITaskRunner, TaskRunner>();
builder.Services.AddSingleton<ITaskExecutor, TaskExecutor>();
builder.Services.AddSingleton<IReportWriter, ReportWriter>();
builder.Services.AddSingleton<IHeartbeatClient, HeartbeatClient>();

var app = builder.Build();
Expand Down
2 changes: 1 addition & 1 deletion MergerService/Runners/ITaskExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace MergerService.Runners
{
public interface ITaskExecutor
{
void ExecuteTask(MergeTask task, ITaskUtils taskUtils, string? managerCallbackUrl);
void ExecuteTask(MergeTask task, ITaskUtils taskUtils, string? managerCallbackUrl, string? reportOutputPath);
}
}
Loading
Loading