Skip to content
Open
93 changes: 88 additions & 5 deletions src/job/models/ingestion/cacheDeletionJobCreator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the ranges here follow tms or wmts (y axis direction)?

const side = degreesPerTile(zoom);
return {
zoom,
minX: Math.ceil((minLon - GRID_MIN_LON) / side) - 1,
minY: Math.ceil((minLat - GRID_MIN_LAT) / side) - 1,
Comment on lines +49 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minX & minY fails the edge case when minLon & minLat equal to GRID_MIN_LON & GRID_MIN_LAT respectively, resulting in -1 index

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<Polygon> => {
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;
Expand Down Expand Up @@ -141,11 +175,22 @@ export class CacheDeletionJobCreator {

private async *buildRangeTasks(job: IngestionFinalizeJob, prefix: string, logger: Logger): AsyncGenerator<CacheDeletionTask> {
const { maxZoom, tileBatchSize, maxRangesPerTask } = this.taskConfig;
const { previousFootprint } = job.parameters.additionalParams;

const updateArea = await this.readProductGeometry(job.parameters.inputFiles.productShapefilePath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it is better to query the catalog for the current footprint. files might be removed at this point in time.

@CL-SHLOMIKONCHA CL-SHLOMIKONCHA Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite, cache deletion job is creating right after a completed ingestion update job which basically mean files won't be deleted,
however, this isn't the catalog footprint but the requested update area.

logger.info({
msg: 'Computing tile ranges over the updated footprint',
maxZoom,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think that maxZoom is taken from the update job. if it is a lower zoom level then previous zoom level, we should take the zoom level from the higher between them

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its true for the update operations, not for cache deletion, cache may store zoom level up to 21 by our definition (upscale) that we have to delete.

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 {
Expand All @@ -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<ITileRange> {
const [minLon, minLat, maxLon, maxLat] = bbox(previousFootprint);
const previousBbox: BBox2d = [minLon, minLat, maxLon, maxLat];
Comment on lines +214 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destructure constants are unused. simply assign previousBbox with bbox(previousFootprint)


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) {
Comment on lines +217 to +235

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are cases in which this algorithm is potentially too much eager, it depends on the outcome from footprintToTileRanges, and on input geometries.
for example, if previous and current footprints reside each in the mirrored areas (2 triangles) around the bbox diagonal. they have the same bbox and so the same tile range. if they share a small areal portion then shared will be true but the yielded area can be as large as each original bbox.

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,
Expand Down
32 changes: 32 additions & 0 deletions src/job/models/ingestion/updateJobHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
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 });
}
}
}
11 changes: 10 additions & 1 deletion src/utils/zod/schemas/jobParameters.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
taskBlockDuplicationParamSchema,
ingestionBaseJobParamsSchema,
ingestionUpdateJobParamsSchema,
multiPolygonSchema,
polygonSchema,
} from '@map-colonies/raster-shared';
import { z } from 'zod';
import { Grid } from '../../../common/interfaces';
Expand Down Expand Up @@ -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<typeof previousFootprintSchema>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PreviousFootprint type it is unused


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(),
Expand Down
Loading
Loading