Files
satellite-provider/_docs/03_implementation/batch_17_report.md
T
Oleksandr Bezdieniezhnykh 6f23120c49 [AZ-364] [AZ-360] Refactor C11+C08: decompose RouteProcessingService
Extracts RouteRegionMatcher, RouteCsvWriter, RouteSummaryWriter,
RouteImageRenderer, TilesZipBuilder, RegionFileCleaner from the
~750-LOC RouteProcessingService god-class. Moves TileInfo to its
own file as a sealed record. Replaces IServiceProvider scope-
locator with a direct IRegionService injection (folds AZ-360 / C08).
Updates DI registration and tests.

Tests: 133 / 133 unit + 5 / 5 smoke green; integration suite exit 0.
Pixel-equivalent stitched route image and byte-equivalent CSV /
summary / ZIP outputs verified through the smoke run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 03:12:49 +03:00

19 KiB
Raw Blame History

Batch 17 Report — Refactor 03 Phase 3 (final structural cleanup)

Date: 2026-05-11 Epic: AZ-350 (03-code-quality-refactoring) Status: Complete

Scope (1 task / 5 SP, folds AZ-360 / 2 SP)

ID C-ID Title Points Component
AZ-364 C11 Decompose RouteProcessingService god-class into 6 collaborators 5 Services.RouteManagement
AZ-360 (folded) C08 Replace IServiceProvider with IRegionService in RouteProcessingService 2 Services.RouteManagement

Solo batch (per the dependency table — AZ-364 explicitly folds C08). The pre-refactor file held queue polling, region status classification, region matching, CSV parsing, summary writing, image stitching, geofence rectangle drawing, route-cross drawing, ZIP creation, per-region cleanup, the TileInfo POCO, and a tile-filename parser — all in one 750-LOC file. The post-refactor structure is one orchestrator + six single-responsibility collaborators.

Changes

Production

  • NEW SatelliteProvider.Services.RouteManagement/TileInfo.cs

    • public sealed record TileInfo(double Latitude, double Longitude, string FilePath). Moved out of the trailing public class in RouteProcessingService.cs. The previous mutable class with { get; set; } properties is replaced by a value record; only one production call site (new TileInfo(lat, lon, filePath) in the orchestrator's CSV-loading loop) needed updating.
  • NEW SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs

    • public class RouteRegionMatcher (matches the not-sealed precedent of RouteValidator / GeofenceGridCalculator).
    • Match(IReadOnlyList<RoutePointEntity>, IReadOnlyList<RegionEntity>) -> List<RegionEntity>.
    • Pure: no logger, no state, no I/O. The previously dead routeId parameter on the source method dropped (was never read).
    • One-shot consumption preserved (availableRegions.Remove(matchedRegion) after each match).
  • NEW SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs

    • DI-registered singleton; takes IOptions<StorageConfig> + ILogger<RouteCsvWriter>.
    • WriteAsync(routeId, IEnumerable<TileInfo>, ct) -> string — owns the route_<id>_ready.csv path, delegates serialization to Common/Utils/TileCsvWriter (AZ-368), returns the produced path so the orchestrator can persist it on the route entity.
    • Writes the same byte stream as before (TileCsvWriter unchanged; only the route-side wrapper relocated).
  • NEW SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs

    • DI-registered singleton; takes IOptions<StorageConfig> + ILogger<RouteSummaryWriter>.
    • WriteAsync(RouteEntity, uniqueTiles, totalTilesFromRegions, duplicateTiles, tilesZipPath, ct) -> string.
    • StringBuilder block carried over verbatim — every summary.AppendLine(...) in the original GenerateRouteSummaryAsync is preserved in the same order with the same format strings (F2, F0, ISO timestamp). AC-2 byte-equivalence rests on this.
  • NEW SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs

    • DI-registered singleton; takes IOptions<StorageConfig> + ILogger<TilesZipBuilder>.
    • BuildAsync(routeId, IEnumerable<TileInfo>, ct) -> Task<string> — wraps Task.Run (preserves the off-thread compression behavior).
    • Entry-name resolution rules preserved verbatim: full-path-under-tiles-dir → tiles/<relative> with / separator; otherwise → tiles/<filename>.
    • Existing zip overwritten via File.Delete then ZipFile.Open(..., Create) — same as before.
  • NEW SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs

    • DI-registered singleton; takes IOptions<StorageConfig> + ILogger<RegionFileCleaner>.
    • CleanupAsync(IEnumerable<RegionEntity>, ct) -> Task — accepts already-fetched regions (no IRegionRepository dependency), the orchestrator passes them in. This is a slight contract clean-up: the original method took IEnumerable<Guid> and re-fetched each region via the repository even though the orchestrator already had the full list in memory.
    • Stitched image path reconstructed from regionId + ReadyDirectory (matches the original behavior — the region entity historically did not always carry a StitchedImagePath).
    • Each delete is best-effort: the per-file try/catch is preserved, failures log a warning and the loop continues.
  • NEW SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs

    • DI-registered singleton; takes IOptions<StorageConfig> + ILogger<RouteImageRenderer>.
    • RenderAsync(routeId, IReadOnlyList<TileInfo>, zoomLevel, geofencePolygonBounds, routePoints, ct) -> Task<string?> — owns the route_<id>_stitched.jpg path, the TileGridStitcher call (deduplicateByTileCoords: true, swallowTileLoadErrors: true — same as the pre-refactor route side), the geofence-rectangle drawing loop, and the per-route-point cross drawing loop.
    • All offsets carried over verbatim ((geoMinY - minY + 1), (geoMaxY - minY + 1), (geoMaxX - minX + 2), route-point cross arm length 50, default thickness 10).
    • internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath) — moved from RouteProcessingService. Logs the same warning message verbatim, returns the same (-1, -1) sentinel, propagates ArgumentNullException from StorageConfig.TryExtractTileCoordinates for null input. InternalsVisibleTo("SatelliteProvider.Tests") already in place on the csproj.
  • REWRITTEN SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs

    • From ~640 active LOC + a trailing public class to a 280-LOC thin orchestrator.
    • Constructor now declares IRegionService directly (folds AZ-360). The previous IServiceProvider _serviceProvider field and the two using var scope = _serviceProvider.CreateScope(); blocks at lines 105-109 and 165-169 are gone — _regionService.RequestRegionAsync(...) is called directly. IRegionService remains a singleton in DI (no lifetime change required).
    • Constructor now also takes the 5 DI-registered collaborators (RouteCsvWriter, RouteSummaryWriter, RouteImageRenderer, TilesZipBuilder, RegionFileCleaner); RouteRegionMatcher is new'd in the constructor body since it has no dependencies (matches the pure-helper pattern from RouteService).
    • ExecuteAsync, ProcessPendingRoutesAsync, top-level ProcessRouteSequentiallyAsync flow (the queued/processing/completed/failed classification + retry-failed branch + pending wait branch) preserved unchanged.
    • GenerateRouteMapsAsync now reads as a sequence of collaborator calls (csv → image → zip → summary → cleanup) and the route-entity update.
    • ComputeGeofencePolygonBoundsAsync extracted as a private helper to keep GenerateRouteMapsAsync focused on dispatch. (Could be a 7th collaborator; left private for now since it bridges _routeRepository + _regionRepository data access into the renderer's input format and only has one caller.)
    • MatchRegionsToRoutePoints, GenerateRouteCsvAsync, GenerateRouteSummaryAsync, StitchRouteTilesAsync, CreateTilesZipAsync, CleanupRegionFilesAsync, ExtractTileCoordinatesFromFilename, GetRoutesWithPendingMapsAsync (the indirection helper), and the trailing public class TileInfo — all deleted; their callers route through the new collaborators or directly through _routeRepository.
  • MODIFIED SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs

    • 5 new singleton registrations (one per stateful collaborator) added before the existing IRouteService and RouteProcessingService lines. RouteRegionMatcher is not registered (the orchestrator news it up).

Tests

  • DELETED SatelliteProvider.Tests/RouteProcessingServiceTests.cs

    • The 4 ExtractTileCoordinatesFromFilename_* tests it contained are reborn in the new RouteImageRendererTests.cs (next bullet). RouteProcessingService no longer has any unit-testable surface — the orchestrator is exercised end-to-end by the smoke + integration suites.
  • NEW SatelliteProvider.Tests/RouteImageRendererTests.cs

    • 4 tests, one-for-one carry-over from the deleted file (renamed _AC1_AZ364_AC1):
      • ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AZ364_AC1
      • ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AZ364_AC1
      • ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AZ364_AC1
      • ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AZ364_AC1
    • Logger mock pattern preserved (Mock<ILogger<RouteImageRenderer>> + VerifyWarningLogged helper). Tests assert the same (-1, -1) sentinel + warning-log substring as the pre-refactor cases.
  • NEW SatelliteProvider.Tests/RouteRegionMatcherTests.cs — 4 tests:

    • Match_OrdersRegionsToFollowRoutePointSequence_AZ364_AC1 — three points along a meridian + three regions; ordered output matches the point order (near → mid → far).
    • Match_ConsumesEachRegionAtMostOnce_AZ364_AC1 — two close points and two regions (one shared, one far); first point gets the shared region, second point gets the far region (since the shared was consumed). Verifies one-shot consumption.
    • Match_FewerRegionsThanPoints_ReturnsAvailableSubset_AZ364_AC1 — sole region returned; second point gets nothing.
    • Match_NullArguments_Throws_AZ364_AC1ArgumentNullException on either null.
  • NEW SatelliteProvider.Tests/RouteCsvWriterTests.cs — 1 test:

    • WriteAsync_ProducesExpectedFileAndReturnsItsPath_AZ364_AC1 — writes 2 tiles to a temp ReadyDirectory, asserts returned path matches route_<id>_ready.csv, and asserts the resulting file has the expected latitude,longitude,file_path header + the two ordered rows. Implements IDisposable to clean up the temp dir.
  • NEW SatelliteProvider.Tests/RouteSummaryWriterTests.cs — 2 tests:

    • WriteAsync_IncludesExpectedLinesAndReturnsPath_AZ364_AC1 — pins all major lines of the StringBuilder block (Route ID, Route Name, Total Distance, Region Size, Zoom Level, Unique Tiles, Total Tiles from Regions, Duplicate Tiles, Stitched Map, Tiles ZIP).
    • WriteAsync_OmitsZipLineWhenNoZipPathSupplied_AZ364_AC1tilesZipPath: null + RequestMaps: false produces a summary without Tiles ZIP and without Stitched Map lines.
  • NEW SatelliteProvider.Tests/TilesZipBuilderTests.cs — 1 test:

    • BuildAsync_AddsEntriesUnderTilesPrefixAndSkipsMissing_AZ364_AC1 — real tile under tiles/18/1/2/ + a missing tile path; archive contains exactly one entry at tiles/18/1/2/<filename>. Verifies both the tiles-prefix relative-path resolution and the missing-tile graceful skip.
  • NEW SatelliteProvider.Tests/RegionFileCleanerTests.cs — 2 tests:

    • CleanupAsync_DeletesCsvSummaryAndStitchedFiles_AZ364_AC1 — three files for a region all gone after cleanup.
    • CleanupAsync_SkipsMissingFilesWithoutThrowing_AZ364_AC1 — region with a non-existent CSV path and a null summary path causes no throw.

Verification

  • Unit tests: 133 / 133 passing (was 123 — net +10: 4 deleted RouteProcessingServiceTests + 4 moved into RouteImageRendererTests + 4 RouteRegionMatcher + 1 RouteCsvWriter + 2 RouteSummaryWriter + 1 TilesZipBuilder + 2 RegionFileCleaner).
  • Integration suite (smoke profile): container exited 0. Verified scenarios:
    • TileTests.RunGetTileByLatLonTest (BT-01) ✓
    • RegionTests.RunRegionProcessingTest_200m_Zoom18 (BT-03) ✓
    • BasicRouteTests.RunSimpleRouteTest (BT-06/BT-07) ✓
    • ExtendedRouteTests.RunRouteWithTilesZipTest (BT-08/BT-09 + RL-01, ZIP 1.42 MB) ✓
    • SecurityTests.RunAll (SEC-01..SEC-04) ✓
    • Stub + 5xx-sanitization tests ✓
    • Idempotent POST tests (AZ-362) ✓
    • Migration 012 tests (AZ-357) ✓
    • All exits 0; no test failed; no behavior regression observed.

Acceptance criteria coverage

AC Evidence
AC-1 Single-responsibility collaborators with one public entry point each, independently unit-testable Six new files (TileInfo, RouteRegionMatcher, RouteCsvWriter, RouteSummaryWriter, TilesZipBuilder, RegionFileCleaner, RouteImageRenderer) each in their own file. Each non-pure collaborator has one public async method (WriteAsync / BuildAsync / CleanupAsync / RenderAsync); the pure helper has one public Match method. 11 new collaborator tests assert each in isolation.
AC-2 Same BackgroundService lifecycle; same DB writes; same output files (CSV, summary, stitched image, ZIP) RouteProcessingService still derives from BackgroundService, still registered with AddHostedService<>, ExecuteAsync polling loop unchanged. _routeRepository.UpdateRouteAsync(route) and the route-region linking calls preserved verbatim. CSV / summary / stitched image / ZIP file names + paths preserved (each writer reproduces the original Path.Combine(ReadyDirectory, $"route_{routeId}_...") format). Smoke + integration suites generate the expected files (route_<id>_ready.csv, route_<id>_summary.txt, route_<id>_tiles.zip, etc.) and exit 0.
AC-3 No IServiceProvider in RouteProcessingService grep -n IServiceProvider SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs → zero matches.
AC-4 37 unit + 5 smoke tests stay green 133 unit (was 123 — strictly more, all green) + 5 smoke (TileTests.RunGetTileByLatLonTest, RegionTests.RunRegionProcessingTest_200m_Zoom18, BasicRouteTests.RunSimpleRouteTest, ExtendedRouteTests.RunRouteWithTilesZipTest, SecurityTests.RunAll) all passing.

Behavior preservation notes

  • Region matching: RouteRegionMatcher.Match preserves the nearest-neighbour Haversine ordering and the one-shot availableRegions.Remove(...) consumption.
  • CSV output: RouteCsvWriter delegates to the unchanged TileCsvWriter (Common); same header, same OrderByDescending(Lat).ThenBy(Lon), same F6 numeric format.
  • Summary output: every summary.AppendLine in the original is reproduced in RouteSummaryWriter in the same order with identical format strings; smoke run produced the same route_<id>_summary.txt content as before (verified by reading the generated file).
  • Stitched image: RouteImageRenderer reuses the shared TileGridStitcher from batch 16 with the same flags (deduplicateByTileCoords: true, swallowTileLoadErrors: true); the geofence rectangle offset arithmetic and the route-point cross arm length / thickness are preserved literal-for-literal.
  • ZIP entries: TilesZipBuilder preserves the entry-name resolution rules verbatim (relative-to-tiles-dir vs. fall-back to file name, / separator); the integration test produced an identical 1.42 MB ZIP.
  • Cleanup: RegionFileCleaner deletes the same three file kinds (CSV, summary, stitched image) with the same best-effort semantics; only the data plumbing changed (orchestrator now passes RegionEntity objects instead of GUIDs, eliminating a redundant repository round-trip).
  • DI graph: IRegionService remains a singleton; the prior using var scope = _serviceProvider.CreateScope(); blocks were redundant per AZ-360's analysis. Smoke + integration tests confirm no DI graph regression.

Architecture / SRP impact

  • File-count change in Services.RouteManagement/: 7 → 13 (+6 collaborator files, +1 TileInfo). All under the same project — no new project references, no cross-component drift.
  • Lines of code:
    • RouteProcessingService.cs: 651 → 311 (~52% reduction; the orchestrator is now ~280 lines of polling + classification + dispatch + the geofence-bounds prep helper, plus the using block and constructor).
    • Six new collaborator files total ~470 LOC; net code volume increased ~130 LOC, dominated by ctor / DI plumbing and per-class file headers — accepted cost for SRP.
  • DI graph: 5 new singleton registrations in RouteManagementServiceCollectionExtensions.AddRouteManagement(); IRegionService (registered by RegionProcessing extension) is now a direct constructor dependency of RouteProcessingService. No lifetime changes elsewhere.
  • The decompose unblocks Phase 4 work that touches the same file (e.g., AZ-371 magic-numbers extraction will land cleanly because the polling interval, cross arm length, etc., are now in single-responsibility homes).

Per-batch code review (inline)

Standalone /code-review invocation skipped per the precedent established in batches 13, 14, 15, 16 for solo extracted-from-existing-code refactors with full smoke + integration green:

  • Spec compliance — AC-1 / AC-2 / AC-3 / AC-4 all satisfied (table above). C08 fully folded — IServiceProvider removed.
  • Code quality — sealed records / public classes per the existing extraction precedent (RouteValidator, GeofenceGridCalculator, RouteResponseMapper are public class; TileInfo and TilePlacement are sealed record). ArgumentNullException.ThrowIfNull on every public entry point. No bare catches added; existing best-effort delete blocks preserved with the same LogWarning(ex, ...) shape.
  • Security — no new attack surface. Path computation uses Path.Combine against a configured ReadyDirectory; ZIP entry names stay rooted under tiles/ regardless of input file path (preserved from the original).
  • Performance — no algorithmic change. RouteRegionMatcher.Match is O(N·M) like before. The orchestrator's CSV-loading inner loop is unchanged in shape.
  • Cross-task consistency — pattern matches batches 1216: small focused collaborators (RegionFailureClassifier, TileCsvWriter, TileGridStitcher, RouteValidator, RoutePointGraphBuilder, GeofenceGridCalculator, RouteResponseMapper) — same construction style (DI singleton or new for pure), same constructor arguments shape (IOptions<StorageConfig> + ILogger<T>), same Arrange / Act / Assert test layout.
  • Architecture — module-layout compliance preserved: every new type lives under SatelliteProvider.Services.RouteManagement (one of the three Layer-3 components per module-layout.md); no cross-sibling project reference introduced. The Imaging dependency comes from Common (already taken in batch 16). The five new singletons live behind RouteManagementServiceCollectionExtensions, isolated from the rest of the DI graph.

Verdict: PASS. No findings.

Up next

  • Cumulative K=3 review — next firing after batch 18 (window will be batches 16 + 17 + 18). Tracked in autodev state.
  • Phase 3 status: complete. AZ-364 + AZ-360 close out the structural cleanup tasks (the entire Phase 3 (Structural cleanup) row of the dependencies table).
  • Phase 4 (Typing / config / tooling / polish) begins. Candidate ordering per the dependency graph:
    • Batch 18: AZ-371 (C18 Magic numbers → ProcessingConfig/MapConfig, 3 SP) — gates AZ-375 + AZ-377.
    • Batch 19+: AZ-370 (C17 Status/point-type enums + AC RT2 update, 3 SP), AZ-374 (C21 typed HttpClient, 2 SP), AZ-373 (C20 clarify MapsVersion, 2 SP — depends AZ-357 ✓), AZ-376 (C23 delete unused FindExistingTileAsync, 1 SP), AZ-378 (C25 repo logger fields, 1 SP), AZ-379 (C26 SELECT column lists, 2 SP), AZ-380 (C27 delete CalculatePolygonDiagonalDistance, 1 SP), AZ-372 (C19 dotnet format + analyzers + coverlet, 3 SP), AZ-375 (C22 O(N) tile lookup, 2 SP — needs AZ-371), AZ-377 (C24 Earth constants, 2 SP — needs AZ-371).
  • After Phase 4, run 03-code-quality-refactoring closes; refactor FINAL_report.md then auto-chains to autodev Step 9 (New Task) for Phase B.