feat: replace QMesh terrain engine with direct GeoTIFF/COG sampling (MAPCO-11560) - #52
shimoncohen wants to merge 25 commits into
Conversation
Deployment TODO (ops) — required before this serves heightsThis PR only changes the service. The following are data/infra steps and must land in
Verified live in |
|
Jira: MAPCO-11560 |
syncush
left a comment
There was a problem hiding this comment.
🔍 Detailed Code Review
Overall, this migration is clean and a great architectural improvement—it eliminates heavy Cesium dependencies, drops unnecessary radian/degree transformations, and streamlines direct COG reading over HTTP range requests.
Below is a detailed breakdown of findings, potential bugs, and suggestions before merging:
🚨 Critical / Major Finding: Input Point Order is Lost in Multi-Provider / No-Provider Scenarios
- File:
src/heights/models/heightsManager.ts(Lines 639–681) - Issue:
samplePositionsHeightsgroups points into buckets based onproviderKey:Because points are bucketed by provider and then the bucket results are flattened withconst groups = new Map<string | null, GeoPoint[]>(); for (const position of positionsWithProviders) { const key = position.providerKey ?? null; const bucket = groups.get(key) ?? []; bucket.push({ longitude: position.longitude, latitude: position.latitude }); groups.set(key, bucket); } ... const { results } = await PromisePool.for(groupEntries) ... finalPositionsWithHeights.push(...(results as PosWithHeight[][]).flat());
.flat(), the returned array does not preserve the original ordering of the inputpointslist when the points span across multiple providers or include points outside of coverage (providerKey === null). - Impact:
Clients calling/pointswith an array[P1, P2, P3](whereP1is in Provider A,P2is out-of-bounds,P3is in Provider A) will receive[P1, P3, P2]in the response instead of[P1, P2, P3]. - Suggested Fix:
Preserve the original index with each position before bucketing, and sort/place results back into their original index slots before returning:// In attachProviderToPositions or samplePositionsHeights, keep track of originalIndex: type IndexedPoint = GeoPoint & { originalIndex: number }; // When assembling finalPositionsWithHeights: const finalPositionsWithHeights: PosWithHeight[] = new Array(positionsArr.length); // Put each sampled point at its originalIndex
⚠️ Performance Consideration: Concurrency of readRasters per Provider
- File:
src/heights/models/geotiffHeightProvider.ts(Lines 264–295) - Observation:
GeotiffHeightProvider.samplemaps all points concurrently:For large point batches (e.g. 500–1000 points in a single request),public async sample(points: GeoPoint[]): Promise<(number | null)[]> { return Promise.all(points.map(async (point) => this.sampleOne(point))); }
Promise.alltriggers hundreds of simultaneousreadRasterswindow calls. Even with internal block caching, uncached blocks will fire simultaneous HTTP range requests, potentially overwhelming gateway sockets or triggering connection limits. - Recommendation:
Consider bounding concurrency (e.g. usingPromisePoolor batching) or clustering points per TIFF tile block if high batch sizes are expected.
💡 Minor Improvements & Code Quality
-
Unused Injected Service in
HeightsManager:- File:
src/heights/models/heightsManager.ts(Line 574) CommonErrorsis injected into the constructor but is never referenced after removingPOINTS_DENSITY_TOO_LOW_ERROR. Either remove it from DI or leave a short comment explaining it is retained for constructor backwards compatibility.
- File:
-
Query Parameter Construction:
- File:
src/heights/models/DEMTerrainCacheManager.ts(Lines 371–374) - String concatenation for query parameters (
${objectUrl}${separator}...) could be replaced with the WHATWGURLAPI (new URL(objectUrl)/searchParams.set(...)) to safely handle existing params, query fragments, and URL encoding.
- File:
-
Coverage for Provider Initialization Failure:
- File:
src/heights/models/DEMTerrainCacheManager.ts(Lines 343–350) initProviderswraps individual provider initialization in atry/catchto skip failed records, which is great. Adding a unit test verifying that one failing record does not prevent subsequent records from being registered would ensure this error resilience doesn't regress.
- File:
✅ Summary
Aside from the point-order preservation fix, the implementation is clean, well-tested, and achieves the goal of eliminating Cesium and switching to direct COG sampling.
Response to PR #52 review (syncush): - Preserve input order in samplePositionsHeights: points were bucketed by provider then flattened, so responses reordered points that spanned multiple providers or fell outside coverage. Now scatter results back to each point's original index. - Bound GeotiffHeightProvider.sample concurrency (config samplingConcurrency, default 16) so large batches don't open N simultaneous gateway connections. - Build query-param authenticated URL with WHATWG URL API. - Drop unused CommonErrors injection from HeightsManager. - Add tests: input-order regression across providers/gaps, and DEMTerrainCacheManager provider-init failure isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
|
Addressed in 2dfc862:
18 unit + 8 integration green, build clean. |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
…odata Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Response to PR #52 review (syncush): - Preserve input order in samplePositionsHeights: points were bucketed by provider then flattened, so responses reordered points that spanned multiple providers or fell outside coverage. Now scatter results back to each point's original index. - Bound GeotiffHeightProvider.sample concurrency (config samplingConcurrency, default 16) so large batches don't open N simultaneous gateway connections. - Build query-param authenticated URL with WHATWG URL API. - Drop unused CommonErrors injection from HeightsManager. - Add tests: input-order regression across providers/gaps, and DEMTerrainCacheManager provider-init failure isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
- samplePositionsHeights: add PromisePool .handleError that rethrows, so a
provider.sample() failure surfaces instead of leaving undefined height slots.
- Snapshot heightProviders + catalogRecordsMap once per request and thread them
through attachProviderToPositions, so a background rebuild mid-request can't
make a chosen providerKey resolve to an undefined provider/record.
- attachProviderToPositions: hoist providerEntries + productTypeFiltered out of
the per-point map (they don't vary by point).
- Only attach productId when defined (drops the undefined-as-string cast).
- transformRouteToObjectUrl: slice from the first 'cogs/' to the end instead of
split('cogs/')[1], which dropped trailing segments on repeated 'cogs/'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATQerkHAV3Vur82HVVFack
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
9ad9fbf to
620d66c
Compare
Summary
geotiff.js). Cesium removed entirely.dataToRadians/dataToDegreesmiddlewares +radiansToOriginalPositionsMap) is deleted.GEOTIFFlinks (wasTERRAIN_QMESH);DEMTerrainCacheManageropens oneGeotiffHeightProviderper record (token auth,cogs/object key).HeightsManager: per-point provider selection (productType + footprint + resolution), group-by-provider, bilinear sampling; nodata (-32768/NaN) and out-of-footprint →null. Per-record provider failures are isolated./pointsAPI contract, response shape, andopenapi3.yamlunchanged.Feasibility verified live on OCP
dem-dev: gateway honors Range (206) with OPA token auth;geotiff.jssampled a real MinIO COG (cogs/dtm_srtm30wgs84geo_tiled256_ovr_lzw.tif) end-to-end.Test Plan
npm run buildcleannpm run test:unit— 16/16, coverage passesnpm run test:integration— 8/8 (incl. seeded happy-path exercising the real sampling chain), coverage passescesium/Cartographic/TERRAIN_QMESH/radiansreferences insrc/testsDeployment (out-of-repo, before this serves heights)
GEOTIFFlink →.../cogs/<file>.tifin bucketdem-dev(today the live record has aTERRAIN_QMESHlink toterrains/srtm100).tiled_ovrCOG (native 30m, tiled, overviews) — not the coarse_COG. Upload the SRTM100 COG if 100m coverage is needed.s3Gateway.url+accessTokenalready point at the internal gateway with queryParam token).🤖 Generated with Claude Code