Skip to content

Commit c3408b0

Browse files
authored
feat: tile rendering pre-process cooldown validation (#39)
* feat: tile processing skip due to cooldown * feat: cooldown functionality improvements * test: aws-sdk requires globalSetup fix * fix: metric name typo
1 parent 78366e9 commit c3408b0

File tree

7 files changed

+2092
-2084
lines changed

7 files changed

+2092
-2084
lines changed

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,22 @@ The fetched metatile image will be splitted into 256x256 pixels tiles in a PNG f
1818
## How it works
1919
```mermaid
2020
flowchart TD
21-
A[Start] --> B{IsQueueEmpty}
21+
A[Start] --> B{Is Queue Empty?}
2222
B -- yes --> G[Finish]
2323
B -- no --> C([Fetch Tile])
24-
C -->D([Get Map])
24+
C --> I{Is Forced?}
25+
I -- no -->J([Get Tile Details])
26+
J -->N([Get Project Details])
27+
N -->M{Is Tile Up To Date?}
28+
M -- yes, skipped -->H
29+
M -- no -->O{Is Cooledowned?}
30+
O -- yes, cooled --> H
31+
O -- no --> D
32+
I -- yes -->D([Get Map])
2533
D -->E([Split Map])
2634
E -->F([Store Tiles])
27-
F --> B
35+
F -- rendered -->H([Upsert Tile Details])
36+
H --> B
2837
```
2938

3039
## config

package-lock.json

Lines changed: 1759 additions & 2053 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@
4545
"@aws-sdk/client-s3": "^3.53.1",
4646
"@godaddy/terminus": "4.9.0",
4747
"@map-colonies/cleanup-registry": "^1.1.0",
48-
"@map-colonies/detiler-client": "^1.0.0",
49-
"@map-colonies/detiler-common": "^1.0.0",
48+
"@map-colonies/detiler-client": "^1.3.0-rc2",
49+
"@map-colonies/detiler-common": "^1.3.0-rc2",
5050
"@map-colonies/error-express-handler": "^2.1.0",
5151
"@map-colonies/express-access-log-middleware": "^1.0.0",
5252
"@map-colonies/js-logger": "^0.0.5",

src/retiler/tileProcessor.ts

Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Logger } from '@map-colonies/js-logger';
33
import { IDetilerClient } from '@map-colonies/detiler-client';
44
import { inject, injectable } from 'tsyringe';
55
import { AxiosInstance } from 'axios';
6+
import { TILEGRID_WORLD_CRS84, tileToBoundingBox } from '@map-colonies/tile-calc';
67
import { IConfig } from '../common/interfaces';
78
import { IProjectConfig } from '../common/interfaces';
89
import { fetchTimestampValue, timestampToUnix } from '../common/util';
@@ -18,13 +19,22 @@ import {
1819
import { MapProvider, MapSplitterProvider, TilesStorageProvider } from './interfaces';
1920
import { TileWithMetadata } from './types';
2021

22+
type SkipReason = 'tile_up_to_date' | 'cooldown';
23+
type ProcessReason = 'project_updated' | 'force' | 'no_detiler' | 'error_occurred';
24+
25+
interface PreProcessReult {
26+
shouldSkipProcessing: boolean;
27+
reason?: ProcessReason | SkipReason;
28+
}
29+
2130
@injectable()
2231
export class TileProcessor {
2332
private readonly project: IProjectConfig;
2433
private readonly forceProcess: boolean;
2534
private readonly detilerProceedOnFailure: boolean;
2635

2736
private readonly tilesCounter?: client.Counter<'status' | 'z'>;
37+
private readonly preProcessResultsCounter?: client.Counter<'result' | 'z'>;
2838
private readonly tilesDurationHistogram?: client.Histogram<'z' | 'kind'>;
2939

3040
public constructor(
@@ -57,6 +67,13 @@ export class TileProcessor {
5767
labelNames: ['status', 'z'] as const,
5868
registers: [registry],
5969
});
70+
71+
this.preProcessResultsCounter = new client.Counter({
72+
name: 'retiler_pre_process_results_count',
73+
help: 'The results of the pre process',
74+
labelNames: ['result', 'z'] as const,
75+
registers: [registry],
76+
});
6077
}
6178
}
6279

@@ -66,8 +83,9 @@ export class TileProcessor {
6683
const preRenderTimestamp = Math.floor(Date.now() / MILLISECONDS_IN_SECOND);
6784

6885
// check if possibly the tile processing can be skipped according to detiler
69-
const shouldSkip = await this.preProcess(tile, preRenderTimestamp);
70-
if (shouldSkip) {
86+
const { shouldSkipProcessing } = await this.preProcess(tile, preRenderTimestamp);
87+
88+
if (shouldSkipProcessing) {
7189
this.tilesCounter?.inc({ status: 'skipped', z: tile.z });
7290
return;
7391
}
@@ -104,16 +122,21 @@ export class TileProcessor {
104122
}
105123
}
106124

107-
private async preProcess(tile: TileWithMetadata, timestamp: number): Promise<boolean> {
108-
const isForced = this.forceProcess || tile.force === true;
125+
private async preProcess(tile: TileWithMetadata, timestamp: number): Promise<PreProcessReult> {
126+
let preProcessTimerEnd;
127+
let result: PreProcessReult = { shouldSkipProcessing: false };
109128

110-
if (this.detiler === undefined || isForced) {
111-
return false;
112-
}
129+
try {
130+
// check for forced rendering or if detiler option is off
131+
const isForced = this.forceProcess || tile.force === true;
113132

114-
const detilerGetTimerEnd = this.tilesDurationHistogram?.startTimer({ kind: 'detilerGet' });
133+
if (isForced || this.detiler === undefined) {
134+
result = { shouldSkipProcessing: false, reason: isForced ? 'force' : 'no_detiler' };
135+
return result;
136+
}
137+
138+
preProcessTimerEnd = this.tilesDurationHistogram?.startTimer({ kind: 'pre_process' });
115139

116-
try {
117140
// attempt to get latest tile details
118141
const tileDetails = await this.detiler.getTileDetails({ kit: this.project.name, z: tile.z, x: tile.x, y: tile.y });
119142

@@ -129,20 +152,77 @@ export class TileProcessor {
129152
if (tileDetails.renderedAt >= projectTimestamp) {
130153
await this.detiler.setTileDetails(
131154
{ kit: this.project.name, z: tile.z, x: tile.x, y: tile.y },
132-
{ hasSkipped: true, state: tile.state, timestamp }
155+
{ status: 'skipped', state: tile.state, timestamp }
133156
);
134-
this.logger.info({ msg: 'skipping tile processing', tile, tileDetails, sourceUpdatedAt: projectTimestamp });
135-
return true;
157+
158+
this.logger.info({
159+
msg: 'tile processing can be skipping due to tile being up do date',
160+
tile,
161+
tileDetails,
162+
sourceUpdatedAt: projectTimestamp,
163+
});
164+
165+
result = { shouldSkipProcessing: true, reason: 'tile_up_to_date' };
166+
167+
return result;
168+
}
169+
170+
// tile geometry in bbox
171+
const { west, south, east, north } = tileToBoundingBox(tile, TILEGRID_WORLD_CRS84, true);
172+
173+
// time elapsed since last rendered
174+
const cooled = timestamp - tileDetails.renderedAt;
175+
176+
// only render if the time elapsed is longer than the relavant cooldowns duration otherwise the tile is still cooling
177+
const cooldownsGenerator = this.detiler.queryCooldownsAsyncGenerator({
178+
enabled: true,
179+
minZoom: tile.z,
180+
maxZoom: tile.z,
181+
kits: [this.project.name],
182+
area: [west, south, east, north],
183+
});
184+
185+
for await (const cooldowns of cooldownsGenerator) {
186+
const isCooling = cooldowns.filter((cooldown) => cooldown.duration > cooled).length > 0;
187+
188+
this.logger.info({
189+
msg: 'tile processing should be skipped due to active cooldown',
190+
tile,
191+
tileDetails,
192+
tileCooled: cooled,
193+
cooldowns,
194+
sourceUpdatedAt: projectTimestamp,
195+
});
196+
197+
if (isCooling) {
198+
await this.detiler.setTileDetails(
199+
{ kit: this.project.name, z: tile.z, x: tile.x, y: tile.y },
200+
{ status: 'cooled', state: tile.state, timestamp }
201+
);
202+
203+
result = { shouldSkipProcessing: true, reason: 'cooldown' };
204+
205+
return result;
206+
}
136207
}
137208
}
138209

139-
return false;
210+
result = { shouldSkipProcessing: false, reason: 'project_updated' };
211+
212+
return result;
140213
} catch (error) {
141214
this.logger.error({ msg: 'an error occurred while pre processing, tile will be processed', error });
142-
return false;
215+
216+
result = { shouldSkipProcessing: false, reason: 'error_occurred' };
217+
218+
return result;
143219
} finally {
144-
if (detilerGetTimerEnd) {
145-
detilerGetTimerEnd();
220+
this.logger.info({ msg: 'pre processing done', tile, result });
221+
222+
this.preProcessResultsCounter?.inc({ result: result.reason, z: tile.z });
223+
224+
if (preProcessTimerEnd) {
225+
preProcessTimerEnd();
146226
}
147227
}
148228
}
@@ -152,18 +232,21 @@ export class TileProcessor {
152232
return;
153233
}
154234

155-
const detilerSetTimerEnd = this.tilesDurationHistogram?.startTimer({ kind: 'detilerSet' });
235+
const postProcessTimerEnd = this.tilesDurationHistogram?.startTimer({ kind: 'post_process' });
156236

157237
try {
158-
await this.detiler.setTileDetails({ kit: this.project.name, z: tile.z, x: tile.x, y: tile.y }, { state: tile.state, timestamp });
238+
await this.detiler.setTileDetails(
239+
{ kit: this.project.name, z: tile.z, x: tile.x, y: tile.y },
240+
{ status: 'rendered', state: tile.state, timestamp }
241+
);
159242
} catch (error) {
160243
this.logger.error({ msg: 'an error occurred while post processing, skipping details set', error });
161244
if (!this.detilerProceedOnFailure) {
162245
throw error;
163246
}
164247
} finally {
165-
if (detilerSetTimerEnd) {
166-
detilerSetTimerEnd();
248+
if (postProcessTimerEnd) {
249+
postProcessTimerEnd();
167250
}
168251
}
169252
}

tests/configurations/integration/jest.globalSetup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { S3Client, CreateBucketCommand, HeadBucketCommand } from '@aws-sdk/clien
33
import config from 'config';
44
import { S3StorageProviderConfig, StorageProviderConfig } from '../../../src/retiler/tilesStorageProvider/interfaces';
55

6+
process.env.ALLOW_CONFIG_MUTATIONS = 'true'; // @aws-sdk/client-s3 attempts to modify config on tests
7+
68
export default async (): Promise<void> => {
79
const storageProvidersConfig = config.get<StorageProviderConfig[]>('app.tilesStorage.providers');
810

tests/integration/retiler.spec.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe('retiler', function () {
5858
let stateInterceptor: nock.Interceptor;
5959
let detilerScope: nock.Scope;
6060
let detilerGetInterceptor: nock.Interceptor;
61+
let cooldownsGetInterceptor: nock.Interceptor;
6162
let detilerPutInterceptor: nock.Interceptor;
6263
let stateBuffer: Buffer;
6364
let mapBuffer2048x2048: Buffer;
@@ -72,7 +73,8 @@ describe('retiler', function () {
7273
getMapInterceptor = nock(mapUrl).defaultReplyHeaders({ 'content-type': 'image/png' }).get(/.*/);
7374
stateInterceptor = nock(stateUrl).get(/.*/);
7475
detilerScope = nock(detilerUrl);
75-
detilerGetInterceptor = detilerScope.get(/.*/);
76+
detilerGetInterceptor = detilerScope.get(/^\/detail/);
77+
cooldownsGetInterceptor = detilerScope.get(/^\/cooldown/);
7678
detilerPutInterceptor = detilerScope.put(/.*/);
7779
stateBuffer = await fsPromises.readFile('tests/state.txt');
7880
mapBuffer512x512 = await fsPromises.readFile('tests/512x512.png');
@@ -83,6 +85,7 @@ describe('retiler', function () {
8385
nock.removeInterceptor(getMapInterceptor);
8486
nock.removeInterceptor(stateInterceptor);
8587
nock.removeInterceptor(detilerGetInterceptor);
88+
nock.removeInterceptor(cooldownsGetInterceptor);
8689
nock.removeInterceptor(detilerPutInterceptor);
8790
jest.clearAllMocks();
8891
});
@@ -297,6 +300,83 @@ describe('retiler', function () {
297300
LONG_RUNNING_TEST
298301
);
299302

303+
it(
304+
'should complete a single job where tile is not skipped even if a cooldown is found',
305+
async function () {
306+
detilerGetInterceptor.reply(httpStatusCodes.OK, { renderedAt: 0 });
307+
cooldownsGetInterceptor.reply(httpStatusCodes.OK, [{ duration: 1 }]);
308+
detilerPutInterceptor.reply(httpStatusCodes.OK);
309+
const stateScope = stateInterceptor.reply(httpStatusCodes.OK, stateBuffer);
310+
const getMapScope = getMapInterceptor.reply(httpStatusCodes.OK, mapBuffer512x512);
311+
312+
const pgBoss = container.resolve(PgBoss);
313+
const provider = container.resolve<PgBossJobQueueProvider>(JOB_QUEUE_PROVIDER);
314+
const queueName = container.resolve<string>(QUEUE_NAME);
315+
const jobId = await pgBoss.send({ name: queueName, data: { z: 1, x: 0, y: 0, metatile: 2, parent: 'parent' } });
316+
317+
const consumePromise = consumeAndProcessFactory(container)();
318+
319+
const storageProviders = container.resolve<TilesStorageProvider[]>(TILES_STORAGE_PROVIDERS);
320+
const storeTileSpies = storageProviders.map((provider) => jest.spyOn(provider, 'storeTile'));
321+
322+
const job = await waitForJobToBeResolved(pgBoss, jobId as string);
323+
await provider.stopQueue();
324+
325+
await expect(consumePromise).resolves.not.toThrow();
326+
327+
expect(job).toHaveProperty('state', 'completed');
328+
329+
storeTileSpies.forEach((spy) => expect(spy.mock.calls).toHaveLength(4));
330+
331+
for (const storeTileSpy of storeTileSpies) {
332+
for (let i = 0; i < 4; i++) {
333+
const storeCall = storeTileSpy.mock.calls[i][0];
334+
const key = determineKey({ x: storeCall.x, y: storeCall.y, z: storeCall.z, metatile: storeCall.metatile });
335+
const expectedBuffer = await fsPromises.readFile(`tests/integration/expected/${key}`);
336+
expect(expectedBuffer.compare(storeCall.buffer)).toBe(0);
337+
}
338+
}
339+
340+
getMapScope.done();
341+
detilerScope.done();
342+
stateScope.done();
343+
},
344+
LONG_RUNNING_TEST
345+
);
346+
347+
it(
348+
'should complete a single job where tile processing is skipped due to cooldown',
349+
async function () {
350+
detilerGetInterceptor.reply(httpStatusCodes.OK, { renderedAt: 0 });
351+
cooldownsGetInterceptor.reply(httpStatusCodes.OK, [{ duration: 9999999999 }]);
352+
detilerPutInterceptor.reply(httpStatusCodes.OK);
353+
const stateScope = stateInterceptor.reply(httpStatusCodes.OK, stateBuffer);
354+
355+
const pgBoss = container.resolve(PgBoss);
356+
const provider = container.resolve<PgBossJobQueueProvider>(JOB_QUEUE_PROVIDER);
357+
const queueName = container.resolve<string>(QUEUE_NAME);
358+
const jobId = await pgBoss.send({ name: queueName, data: { z: 1, x: 0, y: 0, metatile: 2, parent: 'parent' } });
359+
360+
const consumePromise = consumeAndProcessFactory(container)();
361+
362+
const storageProviders = container.resolve<TilesStorageProvider[]>(TILES_STORAGE_PROVIDERS);
363+
const storeTileSpies = storageProviders.map((provider) => jest.spyOn(provider, 'storeTile'));
364+
365+
const job = await waitForJobToBeResolved(pgBoss, jobId as string);
366+
await provider.stopQueue();
367+
368+
await expect(consumePromise).resolves.not.toThrow();
369+
370+
expect(job).toHaveProperty('state', 'completed');
371+
372+
storeTileSpies.forEach((spy) => expect(spy.mock.calls).toHaveLength(0));
373+
374+
detilerScope.done();
375+
stateScope.done();
376+
},
377+
LONG_RUNNING_TEST
378+
);
379+
300380
it(
301381
'should complete a single job where tile is forced',
302382
async function () {

0 commit comments

Comments
 (0)