diff --git a/src/job/models/ingestion/cacheDeletionJobCreator.ts b/src/job/models/ingestion/cacheDeletionJobCreator.ts index 2df4c43..9fc183d 100644 --- a/src/job/models/ingestion/cacheDeletionJobCreator.ts +++ b/src/job/models/ingestion/cacheDeletionJobCreator.ts @@ -1,5 +1,8 @@ import type { Logger } from '@map-colonies/js-logger'; -import { footprintToTileRanges } from '@map-colonies/mc-utils'; +import type { Feature, MultiPolygon, Polygon } from 'geojson'; +import { bbox, bboxPolygon, booleanIntersects } from '@turf/turf'; +import { degreesPerTile, footprintToTileRanges } from '@map-colonies/mc-utils'; +import type { BBox2d, ITileRange } from '@map-colonies/mc-utils'; import type { ICreateJobBody, ICreateTaskBody } from '@map-colonies/mc-priority-queue'; import { OperationStatus, TaskHandler as QueueClient } from '@map-colonies/mc-priority-queue'; import type { CacheDeletionJobParams, LayerName } from '@map-colonies/raster-shared'; @@ -30,6 +33,37 @@ interface CacheDeletionStrategy { * - swap-update: wipes entire prefix (layer redirects to new tiles path) * - update: deletes specific tile ranges over ingested footprint across all zooms */ +const GRID_MIN_LON = -180; +const GRID_MIN_LAT = -90; + +/** + * The tiles whose squares touch the bbox, bounds included: an edge lying exactly on a grid line also claims the + * tile beyond it on either side (ceil - 1 and floor agree everywhere else), as footprintToTileRanges does for + * footprints. Indices may fall outside the grid at the world's edge; they are only ever intersected with ranges + * that lie within it + */ +const tilesTouchingBbox = ([minLon, minLat, maxLon, maxLat]: BBox2d, zoom: number): ITileRange => { + const side = degreesPerTile(zoom); + return { + zoom, + minX: Math.ceil((minLon - GRID_MIN_LON) / side) - 1, + minY: Math.ceil((minLat - GRID_MIN_LAT) / side) - 1, + maxX: Math.floor((maxLon - GRID_MIN_LON) / side), + maxY: Math.floor((maxLat - GRID_MIN_LAT) / side), + }; +}; + +/** the ground a tile range covers */ +const tileRangeToPolygon = (range: ITileRange): Feature => { + const side = degreesPerTile(range.zoom); + return bboxPolygon([ + GRID_MIN_LON + range.minX * side, + GRID_MIN_LAT + range.minY * side, + GRID_MIN_LON + (range.maxX + 1) * side, + GRID_MIN_LAT + (range.maxY + 1) * side, + ]); +}; + @injectable() export class CacheDeletionJobCreator { private readonly taskConfig: CacheDeletionTaskConfig; @@ -141,11 +175,22 @@ export class CacheDeletionJobCreator { private async *buildRangeTasks(job: IngestionFinalizeJob, prefix: string, logger: Logger): AsyncGenerator { const { maxZoom, tileBatchSize, maxRangesPerTask } = this.taskConfig; + const { previousFootprint } = job.parameters.additionalParams; + + const updateArea = await this.readProductGeometry(job.parameters.inputFiles.productShapefilePath); + logger.info({ + msg: 'Computing tile ranges over the updated footprint', + maxZoom, + tileBatchSize, + maxRangesPerTask, + restrictedToPreviousFootprint: previousFootprint !== undefined, + }); - const geometry = await this.readProductGeometry(job.parameters.inputFiles.productShapefilePath); - logger.info({ msg: 'Computing tile ranges over the updated footprint', maxZoom, tileBatchSize, maxRangesPerTask }); - - const ranges = footprintToTileRanges(geometry, { minZoom: 0, maxZoom }); + // without the previous footprint (jobs created before init recorded it) every tile of the update area is covered + const ranges = + previousFootprint === undefined + ? footprintToTileRanges(updateArea, { minZoom: 0, maxZoom }) + : this.rangesTouchingPreviousFootprint(updateArea, previousFootprint, maxZoom, logger); for await (const batch of limitedTileBatchGenerator(tileBatchSize, maxRangesPerTask, ranges)) { yield { @@ -156,6 +201,44 @@ export class CacheDeletionJobCreator { } } + /** + * Per zoom, clips the update ranges to the tile box of the previous bbox and keeps those whose ground touches the + * previous footprint; stops at the first zoom with no shared tile, since deeper zooms cannot share any either. + */ + private *rangesTouchingPreviousFootprint( + updateArea: Polygon | MultiPolygon, + previousFootprint: Polygon | MultiPolygon, + maxZoom: number, + logger: Logger + ): Generator { + const [minLon, minLat, maxLon, maxLat] = bbox(previousFootprint); + const previousBbox: BBox2d = [minLon, minLat, maxLon, maxLat]; + + for (let zoom = 0; zoom <= maxZoom; zoom++) { + const previous = tilesTouchingBbox(previousBbox, zoom); + let shared = false; + + for (const range of footprintToTileRanges(updateArea, { minZoom: zoom, maxZoom: zoom })) { + const clipped: ITileRange = { + zoom, + minX: Math.max(range.minX, previous.minX), + minY: Math.max(range.minY, previous.minY), + maxX: Math.min(range.maxX, previous.maxX), + maxY: Math.min(range.maxY, previous.maxY), + }; + if (clipped.minX <= clipped.maxX && clipped.minY <= clipped.maxY && booleanIntersects(tileRangeToPolygon(clipped), previousFootprint)) { + shared = true; + yield clipped; + } + } + + if (!shared) { + logger.info({ msg: 'Update area stops sharing tiles with the previous footprint, ending the range walk', lastZoom: zoom - 1, maxZoom }); + return; + } + } + } + private async createJobWithStreamedTasks( job: IngestionFinalizeJob, jobType: string, diff --git a/src/job/models/ingestion/updateJobHandler.ts b/src/job/models/ingestion/updateJobHandler.ts index b414a0d..e8033d0 100644 --- a/src/job/models/ingestion/updateJobHandler.ts +++ b/src/job/models/ingestion/updateJobHandler.ts @@ -10,6 +10,7 @@ import { IngestionUpdateFinalizeTask, IngestionUpdateCreateTasksJob, } from '../../../utils/zod/schemas/job.schema'; +import { previousFootprintSchema } from '../../../utils/zod/schemas/jobParameters.schema'; import { PolygonPartsMangerClient } from '../../../httpClients/polygonPartsMangerClient'; import { CatalogClient } from '../../../httpClients/catalogClient'; import type { ReadProductGeometry } from '../../../utils/storage/productReader'; @@ -66,6 +67,8 @@ export class UpdateJobHandler const layerRelativePath = join(job.internalId, additionalParams.displayPath); + await this.recordPreviousFootprint(job, job.internalId, logger); + logger.info({ msg: 'building tasks' }); const { polygonPartsEntityName } = this.validateAndGenerateLayerNameFormats(job); @@ -133,4 +136,33 @@ export class UpdateJobHandler } ); } + + /** + * Saves the catalog footprint from before this update on the job, so the cache deletion at finalize can limit itself + * to the previously covered ground. Best effort: without it the cache deletion covers the whole update area. + */ + private async recordPreviousFootprint(job: IngestionUpdateCreateTasksJob, catalogId: string, logger: Logger): Promise { + const activeSpan = trace.getActiveSpan(); + + try { + const { metadata } = await this.catalogClient.findLayer(catalogId); + const previousFootprint = previousFootprintSchema.parse(metadata.footprint); + + await this.queueClient.jobManagerClient.updateJob(job.id, { + parameters: { ...job.parameters, additionalParams: { ...job.parameters.additionalParams, previousFootprint } }, + }); + + logger.info({ msg: 'Recorded the previous layer footprint on the job for the cache deletion', catalogId }); + activeSpan?.addEvent('recordPreviousFootprint.success', { catalogId }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logger.warn({ + msg: 'Could not record the previous layer footprint, the cache deletion will cover the whole update area', + catalogId, + reason, + err, + }); + activeSpan?.addEvent('recordPreviousFootprint.skipped', { catalogId, reason }); + } + } } diff --git a/src/utils/zod/schemas/jobParameters.schema.ts b/src/utils/zod/schemas/jobParameters.schema.ts index d7cde52..14ff424 100644 --- a/src/utils/zod/schemas/jobParameters.schema.ts +++ b/src/utils/zod/schemas/jobParameters.schema.ts @@ -7,6 +7,8 @@ import { taskBlockDuplicationParamSchema, ingestionBaseJobParamsSchema, ingestionUpdateJobParamsSchema, + multiPolygonSchema, + polygonSchema, } from '@map-colonies/raster-shared'; import { z } from 'zod'; import { Grid } from '../../../common/interfaces'; @@ -39,7 +41,14 @@ export const internalIdSchema = z.object({ }); //#region AdditionalParams -export const updateFinalizeAdditionalParamsSchema = updateAdditionalParamsSchema; +/** the layer's catalog footprint as it stood before the update, recorded by the update job's init step */ +export const previousFootprintSchema = polygonSchema.or(multiPolygonSchema); +export type PreviousFootprint = z.infer; + +export const updateFinalizeAdditionalParamsSchema = updateAdditionalParamsSchema.extend({ + // optional so jobs created before overseer recorded it still finalize; cache deletion then covers the whole update area + previousFootprint: previousFootprintSchema.optional(), +}); export const swapUpdateFinalizeAdditionalParamsSchema = updateFinalizeAdditionalParamsSchema.extend({ displayPath: updateAdditionalParamsSchema.shape.displayPath.optional(), diff --git a/tests/unit/job/cacheDeletionJobCreator/cacheDeletionJobCreator.spec.ts b/tests/unit/job/cacheDeletionJobCreator/cacheDeletionJobCreator.spec.ts index b6cd4ba..3afa34c 100644 --- a/tests/unit/job/cacheDeletionJobCreator/cacheDeletionJobCreator.spec.ts +++ b/tests/unit/job/cacheDeletionJobCreator/cacheDeletionJobCreator.spec.ts @@ -3,7 +3,9 @@ import { randomUUID } from 'node:crypto'; import type { MultiPolygon, Polygon } from 'geojson'; import { OperationStatus } from '@map-colonies/mc-priority-queue'; import { StorageProvider, type CacheDeletionJobParams } from '@map-colonies/raster-shared'; +import { degreesPerTile } from '@map-colonies/mc-utils'; import type { CreateCacheDeletionJobParams, CacheDeletionTaskConfig, GetMapproxyCacheResponse } from '../../../../src/common/interfaces'; +import type { IngestionUpdateFinalizeJob } from '../../../../src/utils/zod/schemas/job.schema'; import { registerDefaultConfig, configMock, setValue } from '../../mocks/configMock'; import { createFakePolygonalGeometry } from '../../mocks/geometryMockData'; import { LayerCacheType } from '../../../../src/common/constants'; @@ -151,7 +153,7 @@ describe('CacheDeletionJobCreator', () => { } }); - it('should cover every zoom from 0 to maxZoom', async () => { + it('should cover the whole update area at every zoom from 0 to maxZoom when no previous footprint is recorded', async () => { const { cacheDeletionJobCreator, jobManagerClientMock, mapproxyClientMock, readProductGeometryMock } = ctx; const taskConfig = configMock.get('jobManagement.ingestion.tasks.cacheDeletion'); @@ -172,6 +174,253 @@ describe('CacheDeletionJobCreator', () => { expect(Math.max(...zooms)).toBe(taskConfig.maxZoom); }); + describe('with the previous layer footprint recorded on the job', () => { + interface Range { + zoom: number; + minX: number; + maxX: number; + minY: number; + maxY: number; + } + interface Tile { + zoom: number; + x: number; + y: number; + } + interface Bounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; + } + + // axis-aligned squares: tile/footprint contact reduces to interval overlap, which keeps the exhaustive + // oracle below trivial + const square = (minLon: number, minLat: number, width: number, height = width): Polygon => ({ + type: 'Polygon', + coordinates: [ + [ + [minLon, minLat], + [minLon + width, minLat], + [minLon + width, minLat + height], + [minLon, minLat + height], + [minLon, minLat], + ], + ], + }); + const PREVIOUS = square(0, 0, 1); + + const withPreviousFootprint = (previousFootprint: Polygon): IngestionUpdateFinalizeJob => ({ + ...ingestionUpdateFinalizeJob, + parameters: { + ...ingestionUpdateFinalizeJob.parameters, + additionalParams: { ...ingestionUpdateFinalizeJob.parameters.additionalParams, previousFootprint }, + }, + }); + + const emittedRanges = (jobManagerClientMock: CacheDeletionJobCreatorTestContext['jobManagerClientMock']): Range[] => { + const streamed = jobManagerClientMock.createTaskForJob.mock.calls.flatMap((call) => call[1] as { parameters: unknown }[]); + const tasks = [...jobManagerClientMock.createJob.mock.calls[0]![0].tasks!, ...streamed]; + return tasks.flatMap((task) => (task.parameters as { ranges: Range[] }).ranges); + }; + + const covers = (ranges: Range[], tile: Tile): boolean => + ranges.some( + (range) => range.zoom === tile.zoom && tile.x >= range.minX && tile.x <= range.maxX && tile.y >= range.minY && tile.y <= range.maxY + ); + + // west edge of a tile column on the geodetic grid + const tileMinLon = (zoom: number, x: number): number => -180 + x * degreesPerTile(zoom); + + const boundsOf = (polygon: Polygon): Bounds => { + const ring = polygon.coordinates[0]!; + return { + minLon: Math.min(...ring.map((point) => point[0]!)), + maxLon: Math.max(...ring.map((point) => point[0]!)), + minLat: Math.min(...ring.map((point) => point[1]!)), + maxLat: Math.max(...ring.map((point) => point[1]!)), + }; + }; + + // exhaustive oracle: every tile at `zoom` whose square touches both footprints, with closed bounds + // since footprintToTileRanges counts a shared edge as touching + const tilesTouchingBoth = (a: Polygon, b: Polygon, zoom: number): Tile[] => { + const side = degreesPerTile(zoom); + const [boundsA, boundsB] = [boundsOf(a), boundsOf(b)]; + const touches = (bounds: Bounds, minLon: number, minLat: number): boolean => + minLon <= bounds.maxLon && minLon + side >= bounds.minLon && minLat <= bounds.maxLat && minLat + side >= bounds.minLat; + + const tiles: Tile[] = []; + for (let x = Math.floor((boundsA.minLon + 180) / side) - 1; x <= Math.floor((boundsA.maxLon + 180) / side) + 1; x++) { + for (let y = Math.floor((boundsA.minLat + 90) / side) - 1; y <= Math.floor((boundsA.maxLat + 90) / side) + 1; y++) { + const minLon = tileMinLon(zoom, x); + const minLat = -90 + y * side; + if (touches(boundsA, minLon, minLat) && touches(boundsB, minLon, minLat)) { + tiles.push({ zoom, x, y }); + } + } + } + return tiles; + }; + + it('should walk no deeper than the last zoom whose tiles can still span both areas when the update lies outside the previous footprint', async () => { + const { cacheDeletionJobCreator, jobManagerClientMock, mapproxyClientMock, readProductGeometryMock } = ctx; + const taskConfig = configMock.get('jobManagement.ingestion.tasks.cacheDeletion'); + // 1.5 degrees east of PREVIOUS. A zoom 6 tile is 2.81 degrees wide and can span the gap; a zoom 7 tile is + // 1.41 wide and cannot, so no tile touches both areas from there on + const UPDATE = square(2.5, 0, 1); + const LAST_BRIDGING_ZOOM = 6; + + mapproxyClientMock.getRedisCache.mockResolvedValue(redisCache('layer-Orthophoto')); + readProductGeometryMock.mockResolvedValue(UPDATE); + jobManagerClientMock.createJob.mockResolvedValue({ id: randomUUID(), taskIds: [randomUUID()] }); + jobManagerClientMock.createTaskForJob.mockResolvedValue(undefined); + + await cacheDeletionJobCreator.create({ layerName: 'layer-Orthophoto', ingestionJob: withPreviousFootprint(PREVIOUS) }); + + const ranges = emittedRanges(jobManagerClientMock); + const zooms = ranges.map((range) => range.zoom); + + expect(Math.min(...zooms)).toBe(0); + expect(Math.max(...zooms)).toBe(LAST_BRIDGING_ZOOM); + // the full walk would have gone on to the configured ceiling + expect(LAST_BRIDGING_ZOOM).toBeLessThan(taskConfig.maxZoom); + + // nothing that genuinely touches both areas is dropped + for (let zoom = 0; zoom <= LAST_BRIDGING_ZOOM; zoom++) { + for (const tile of tilesTouchingBoth(UPDATE, PREVIOUS, zoom)) { + expect(covers(ranges, tile)).toBe(true); + } + } + + // and that check is not vacuous: at zoom 6 a single 2.8-degree tile spans the gap + expect(tilesTouchingBoth(UPDATE, PREVIOUS, 6).length).toBeGreaterThan(0); + }); + + it('should reach the configured max zoom over the overlap but stay out of the new-only ground beyond it', async () => { + // a shallow ceiling keeps the walk over the overlap small + const MAX_ZOOM = 10; + setValue('jobManagement.ingestion.tasks.cacheDeletion.maxZoom', MAX_ZOOM); + const { cacheDeletionJobCreator, jobManagerClientMock, mapproxyClientMock, readProductGeometryMock } = + await setupCacheDeletionJobCreatorTest(); + // overlaps PREVIOUS on lon 0.5..1 and extends onto new ground up to lon 1.5 + const UPDATE = square(0.5, 0, 1); + const NEW_GROUND_EDGE = 1; + + mapproxyClientMock.getRedisCache.mockResolvedValue(redisCache('layer-Orthophoto')); + readProductGeometryMock.mockResolvedValue(UPDATE); + jobManagerClientMock.createJob.mockResolvedValue({ id: randomUUID(), taskIds: [randomUUID()] }); + jobManagerClientMock.createTaskForJob.mockResolvedValue(undefined); + + await cacheDeletionJobCreator.create({ layerName: 'layer-Orthophoto', ingestionJob: withPreviousFootprint(PREVIOUS) }); + + const ranges = emittedRanges(jobManagerClientMock); + + expect(Math.max(...ranges.map((range) => range.zoom))).toBe(MAX_ZOOM); + + for (let zoom = 0; zoom <= MAX_ZOOM; zoom++) { + for (const tile of tilesTouchingBoth(UPDATE, PREVIOUS, zoom)) { + expect(covers(ranges, tile)).toBe(true); + } + } + + // at the deepest zoom nothing starts east of the tile holding PREVIOUS's east edge: the new-only ground + // beyond that was never cached and is left alone + const deepest = ranges.filter((range) => range.zoom === MAX_ZOOM); + + expect(deepest.length).toBeGreaterThan(0); + + for (const range of deepest) { + expect(tileMinLon(MAX_ZOOM, range.maxX)).toBeLessThanOrEqual(NEW_GROUND_EDGE); + } + // whereas the update area itself does extend past that tile, so the full walk would have gone there + const farEdgeTile = Math.floor((boundsOf(UPDATE).maxLon + 180) / degreesPerTile(MAX_ZOOM)); + + expect(tileMinLon(MAX_ZOOM, farEdgeTile)).toBeGreaterThan(NEW_GROUND_EDGE); + }); + + it('should stop early when the update sits inside the previous bbox but outside the previous footprint', async () => { + const { cacheDeletionJobCreator, jobManagerClientMock, mapproxyClientMock, readProductGeometryMock } = ctx; + const taskConfig = configMock.get('jobManagement.ingestion.tasks.cacheDeletion'); + // an L-shaped layer whose bbox is the unit square, with the update in the notch: the bbox clip alone would + // keep every update tile to max zoom, the footprint check drops them once no tile touches both. The notch + // starts 0.35 away from both arms; a zoom 8 tile is 0.70 wide and spans the gap, a zoom 9 tile (0.35 wide, + // grid-aligned at 0.35) stops just short of the arms + const PREVIOUS_L: Polygon = { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 0.3], + [0.3, 0.3], + [0.3, 1], + [0, 1], + [0, 0], + ], + ], + }; + const UPDATE = square(0.65, 0.65, 0.2); + const LAST_REACHING_ZOOM = 8; + + mapproxyClientMock.getRedisCache.mockResolvedValue(redisCache('layer-Orthophoto')); + readProductGeometryMock.mockResolvedValue(UPDATE); + jobManagerClientMock.createJob.mockResolvedValue({ id: randomUUID(), taskIds: [randomUUID()] }); + jobManagerClientMock.createTaskForJob.mockResolvedValue(undefined); + + await cacheDeletionJobCreator.create({ layerName: 'layer-Orthophoto', ingestionJob: withPreviousFootprint(PREVIOUS_L) }); + + const ranges = emittedRanges(jobManagerClientMock); + const zooms = ranges.map((range) => range.zoom); + + expect(Math.min(...zooms)).toBe(0); + expect(Math.max(...zooms)).toBe(LAST_REACHING_ZOOM); + expect(LAST_REACHING_ZOOM).toBeLessThan(taskConfig.maxZoom); + + // the coarse tiles a square-based oracle cannot judge for an L are checked by brute force instead: + // every tile touching both the update and either arm of the L is covered + const ARMS = [square(0, 0, 1, 0.3), square(0, 0, 0.3, 1)]; + for (let zoom = 0; zoom <= LAST_REACHING_ZOOM; zoom++) { + for (const arm of ARMS) { + for (const tile of tilesTouchingBoth(UPDATE, arm, zoom)) { + expect(covers(ranges, tile)).toBe(true); + } + } + } + }); + + it('should clip in grid tiles, unaffected by latitude, for a layer far from the equator', async () => { + const { cacheDeletionJobCreator, jobManagerClientMock, mapproxyClientMock, readProductGeometryMock } = ctx; + // a layer near 35E 32N with an update 0.05 degrees east of it. A zoom 11 tile is 0.088 wide and spans + // the gap; a zoom 12 tile (0.044 wide) does not + const PREVIOUS_NORTH = square(34.9, 32, 0.2); + const UPDATE = square(35.15, 32.05, 0.1); + const LAST_BRIDGING_ZOOM = 11; + + mapproxyClientMock.getRedisCache.mockResolvedValue(redisCache('layer-Orthophoto')); + readProductGeometryMock.mockResolvedValue(UPDATE); + jobManagerClientMock.createJob.mockResolvedValue({ id: randomUUID(), taskIds: [randomUUID()] }); + jobManagerClientMock.createTaskForJob.mockResolvedValue(undefined); + + await cacheDeletionJobCreator.create({ layerName: 'layer-Orthophoto', ingestionJob: withPreviousFootprint(PREVIOUS_NORTH) }); + + const ranges = emittedRanges(jobManagerClientMock); + const zooms = ranges.map((range) => range.zoom); + + expect(Math.min(...zooms)).toBe(0); + expect(Math.max(...zooms)).toBe(LAST_BRIDGING_ZOOM); + + for (let zoom = 0; zoom <= LAST_BRIDGING_ZOOM; zoom++) { + for (const tile of tilesTouchingBoth(UPDATE, PREVIOUS_NORTH, zoom)) { + expect(covers(ranges, tile)).toBe(true); + } + } + + // not vacuous: at zoom 11 a 0.088-degree tile still spans the gap + expect(tilesTouchingBoth(UPDATE, PREVIOUS_NORTH, 11).length).toBeGreaterThan(0); + }); + }); + it('should stream the remaining tasks with createTaskForJob once the first batch fills the job', async () => { const jobId = randomUUID(); diff --git a/tests/unit/job/updateJobHandler/updateJobHandler.spec.ts b/tests/unit/job/updateJobHandler/updateJobHandler.spec.ts index beecbab..c752574 100644 --- a/tests/unit/job/updateJobHandler/updateJobHandler.spec.ts +++ b/tests/unit/job/updateJobHandler/updateJobHandler.spec.ts @@ -2,6 +2,7 @@ import { updateAdditionalParamsSchema } from '@map-colonies/raster-shared'; import { finalizeTaskForIngestionUpdate, createTasksTaskForIngestionUpdate } from '../../mocks/tasksMockData'; import { createFakePolygonalGeometry } from '../../mocks/geometryMockData'; +import { layerRecord } from '../../mocks/catalogClientMockData'; import { registerDefaultConfig } from '../../mocks/configMock'; import { ingestionUpdateFinalizeJob, ingestionUpdateJob } from '../../mocks/jobsMockData'; import { setupUpdateJobHandlerTest, type UpdateJobHandlerTestContext } from './updateJobHandlerSetup'; @@ -42,6 +43,8 @@ describe('updateJobHandler', () => { const polygonPartsEntityName = `${job.resourceId}_${String(job.productType).toLowerCase()}`; readProductGeometryMock.mockResolvedValue(productGeometry); + catalogClientMock.findLayer.mockResolvedValue(layerRecord); + jobManagerClientMock.updateJob.mockResolvedValue(undefined); tileDeletionTaskManagerMock.buildAndPushTasks.mockResolvedValue(undefined); taskBuilderMock.buildAndPushTasks.mockResolvedValue(undefined); queueClientMock.ack.mockResolvedValue(undefined); @@ -53,6 +56,52 @@ describe('updateJobHandler', () => { expect(queueClientMock.ack).toHaveBeenCalledWith(job.id, task.id); }); + it('should record the previous layer footprint on the job before building tasks', async () => { + const job = structuredClone(ingestionUpdateJob); + const task = createTasksTaskForIngestionUpdate; + + readProductGeometryMock.mockResolvedValue(createFakePolygonalGeometry()); + catalogClientMock.findLayer.mockResolvedValue(layerRecord); + jobManagerClientMock.updateJob.mockResolvedValue(undefined); + tileDeletionTaskManagerMock.buildAndPushTasks.mockResolvedValue(undefined); + taskBuilderMock.buildAndPushTasks.mockResolvedValue(undefined); + queueClientMock.ack.mockResolvedValue(undefined); + + await updateJobHandler.handleJobInit(job, task); + + expect(catalogClientMock.findLayer).toHaveBeenCalledWith(job.internalId); + // finalize reads it back from the job, by which time the catalog already holds the merged footprint + expect(jobManagerClientMock.updateJob).toHaveBeenCalledWith(job.id, { + parameters: { + ...job.parameters, + additionalParams: { ...job.parameters.additionalParams, previousFootprint: layerRecord.metadata.footprint }, + }, + }); + expect(jobManagerClientMock.updateJob.mock.invocationCallOrder[0]).toBeLessThan(taskBuilderMock.buildAndPushTasks.mock.invocationCallOrder[0]!); + }); + + it('should still build the tasks when the previous footprint cannot be recorded', async () => { + const job = structuredClone(ingestionUpdateJob); + const task = createTasksTaskForIngestionUpdate; + + readProductGeometryMock.mockResolvedValue(createFakePolygonalGeometry()); + catalogClientMock.findLayer.mockRejectedValue(new Error('catalog unavailable')); + tileDeletionTaskManagerMock.buildAndPushTasks.mockResolvedValue(undefined); + taskBuilderMock.buildAndPushTasks.mockResolvedValue(undefined); + queueClientMock.ack.mockResolvedValue(undefined); + + await updateJobHandler.handleJobInit(job, task); + + const recordedFootprint = jobManagerClientMock.updateJob.mock.calls.some( + ([, body]) => + (body as { parameters?: { additionalParams?: Record } }).parameters?.additionalParams?.previousFootprint !== undefined + ); + + expect(recordedFootprint).toBe(false); + expect(taskBuilderMock.buildAndPushTasks).toHaveBeenCalledTimes(1); + expect(queueClientMock.ack).toHaveBeenCalledWith(job.id, task.id); + }); + it('should reject task when mergeTaskManager.buildAndPushTasks throws', async () => { const job = structuredClone(ingestionUpdateJob); const task = createTasksTaskForIngestionUpdate; diff --git a/tests/unit/job/updateJobHandler/updateJobHandlerSetup.ts b/tests/unit/job/updateJobHandler/updateJobHandlerSetup.ts index 9acdf26..5ab7ca5 100644 --- a/tests/unit/job/updateJobHandler/updateJobHandlerSetup.ts +++ b/tests/unit/job/updateJobHandler/updateJobHandlerSetup.ts @@ -40,7 +40,7 @@ export const setupUpdateJobHandlerTest = async (): Promise; const mapproxyClientMock = { publish: vi.fn() } as unknown as Mocked; - const catalogClientMock = { publish: vi.fn(), update: vi.fn() } as unknown as Mocked; + const catalogClientMock = { publish: vi.fn(), update: vi.fn(), findLayer: vi.fn() } as unknown as Mocked; const cacheDeletionJobCreatorMock = { create: vi.fn() } as unknown as Mocked; const updateJobHandler = new UpdateJobHandler(