From 11803cf9a40c3376fa5b2c2db31a1067b9ae6292 Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 10:01:20 +0300 Subject: [PATCH 1/9] fix: update default values in values.yaml to be empty --- helm/values.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/helm/values.yaml b/helm/values.yaml index 6652853..23a1633 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -17,14 +17,14 @@ storage: forcePathStyle: true secretName: "" sslEnabled: false - region: "us-east-1" + region: "" tilesBucket: "" fs: internalPvc: enabled: false name: "" mountPath: "" - tilesSubPath: "tiles" + tilesSubPath: "" mclabels: @@ -46,8 +46,8 @@ fullnameOverride: "" configManagement: offlineMode: false name: 'cleaner' - version: 'latest' - serverUrl: 'http://localhost:8080/api' + version: '' + serverUrl: '' jobDefinitions: {} @@ -106,7 +106,7 @@ env: resourceAttributes: {} tracing: enabled: false - url: http://localhost:55681/v1/trace + url: "" queue: heartbeatIntervalMs: 1000 dequeueIntervalMs: 3000 From 31514d7f7e02c4829ce18a3773d76ed1be7d8016 Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 10:05:10 +0300 Subject: [PATCH 2/9] feat: enhance error handling and reporting in storage providers with detailed failure reasons --- src/cleaner/errors/errors.ts | 18 +++++ src/cleaner/errors/index.ts | 2 +- .../storageProviders/deleteFailureSummary.ts | 33 ++++++++ .../storageProviders/fsStorageProvider.ts | 26 +++---- .../storageProviders/iStorageProvider.ts | 14 +++- src/cleaner/storageProviders/index.ts | 3 +- .../storageProviders/s3StorageProvider.ts | 24 +++--- .../strategies/tilesDeletionStrategy.ts | 57 ++++++++------ .../deleteFailureSummary.spec.ts | 76 +++++++++++++++++++ .../fsStorageProvider.spec.ts | 27 +++++-- .../s3StorageProvider.spec.ts | 39 +++++++--- tests/tilesDeletionStrategy.spec.ts | 33 +++++++- 12 files changed, 279 insertions(+), 73 deletions(-) create mode 100644 src/cleaner/storageProviders/deleteFailureSummary.ts create mode 100644 tests/storageProviders/deleteFailureSummary.spec.ts diff --git a/src/cleaner/errors/errors.ts b/src/cleaner/errors/errors.ts index 5758c62..bbcdd2e 100644 --- a/src/cleaner/errors/errors.ts +++ b/src/cleaner/errors/errors.ts @@ -13,6 +13,24 @@ export function toError(value: unknown): Error { } } +/** + * Produces a short, human-readable label for a thrown value. Prefers Node `errno` + * codes (e.g. 'ENOENT') when present, then falls back to the error message, then + * to a stringified form. Intended for compact failure summaries surfaced to callers + * (task rejection reasons, grouped failure counts) — not for full stack traces. + */ +export function describeError(value: unknown): string { + if (value instanceof Error) { + const code = (value as NodeJS.ErrnoException).code; + return code ?? value.message; + } + try { + return String(value); + } catch { + return 'non-serializable thrown value'; + } +} + /** * Base class for recoverable errors that can be retried. * Task will be retried if attempts < maxAttempts. diff --git a/src/cleaner/errors/index.ts b/src/cleaner/errors/index.ts index ebada70..0315ef3 100644 --- a/src/cleaner/errors/index.ts +++ b/src/cleaner/errors/index.ts @@ -1,2 +1,2 @@ -export { toError, RecoverableError, UnrecoverableError, ConfigurationError, ValidationError, StrategyNotFoundError } from './errors'; +export { toError, describeError, RecoverableError, UnrecoverableError, ConfigurationError, ValidationError, StrategyNotFoundError } from './errors'; export { ErrorHandler } from './errorHandler'; diff --git a/src/cleaner/storageProviders/deleteFailureSummary.ts b/src/cleaner/storageProviders/deleteFailureSummary.ts new file mode 100644 index 0000000..2cd7aad --- /dev/null +++ b/src/cleaner/storageProviders/deleteFailureSummary.ts @@ -0,0 +1,33 @@ +import type { DeleteFailure } from './iStorageProvider'; + +/** + * Aggregated view of a batch of delete failures — designed to be embedded in a + * task rejection reason or log line without further post-processing. + */ +export interface DeleteFailureSummary { + /** Raw count per reason string, e.g. `{ ENOENT: 150, EACCES: 3 }`. */ + counts: Record; + /** Reasons formatted descending by count, e.g. `'ENOENT=150, EACCES=3'`. */ + summary: string; + /** Up to `sampleSize` `path (reason)` strings, preserving input order. */ + sample: string[]; +} + +/** + * Reduces a list of provider delete failures into a compact, log-friendly + * shape. Lives alongside `IStorageProvider` because it operates purely on + * `DeleteFailure[]` — any caller of `provider.delete()` can use it, regardless + * of which storage backend produced the failures. + */ +export function summarizeDeleteFailures(failures: DeleteFailure[], sampleSize: number): DeleteFailureSummary { + const counts: Record = {}; + for (const { reason } of failures) { + counts[reason] = (counts[reason] ?? 0) + 1; + } + const summary = Object.entries(counts) + .sort(([, a], [, b]) => b - a) + .map(([reason, count]) => `${reason}=${count}`) + .join(', '); + const sample = failures.slice(0, sampleSize).map((f) => `${f.path} (${f.reason})`); + return { counts, summary, sample }; +} diff --git a/src/cleaner/storageProviders/fsStorageProvider.ts b/src/cleaner/storageProviders/fsStorageProvider.ts index fdb4e2b..87734d5 100644 --- a/src/cleaner/storageProviders/fsStorageProvider.ts +++ b/src/cleaner/storageProviders/fsStorageProvider.ts @@ -1,7 +1,8 @@ import { join } from 'node:path'; import { stat, unlink, rmdir } from 'node:fs/promises'; import type { Logger } from '@map-colonies/js-logger'; -import type { IStorageProvider } from './iStorageProvider'; +import { describeError } from '../errors'; +import type { DeleteFailure, IStorageProvider } from './iStorageProvider'; export class FsStorageProvider implements IStorageProvider { public constructor(private readonly logger: Logger) {} @@ -16,7 +17,7 @@ export class FsStorageProvider implements IStorageProvider { } } - public async delete(paths: string[], storageTarget: string): Promise { + public async delete(paths: string[], storageTarget: string): Promise { if (paths.length === 0) { return []; } @@ -25,31 +26,24 @@ export class FsStorageProvider implements IStorageProvider { const results = await Promise.allSettled( paths.map(async (relativePath) => { - try { - await unlink(join(storageTarget, relativePath)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return relativePath; // treat missing file as a failed deletion, to be included in the failure report - } - throw error; - } + await unlink(join(storageTarget, relativePath)); }) ); - const failedPaths: string[] = []; + const failures: DeleteFailure[] = []; for (const [idx, result] of results.entries()) { if (result.status === 'rejected') { const relativePath = paths[idx]!; - this.logger.warn({ msg: 'Failed to delete file', path: join(storageTarget, relativePath), error: result.reason }); - failedPaths.push(relativePath); - } else if (result.value !== undefined) { - failedPaths.push(result.value); + const error: unknown = result.reason; + const reason = describeError(error); + this.logger.debug({ msg: 'Failed to delete file', path: join(storageTarget, relativePath), reason, error }); + failures.push({ path: relativePath, reason }); } } await this.cleanupEmptyDirs(paths, storageTarget); - return failedPaths; + return failures; } // Attempts to remove any directories that became empty after file deletion. diff --git a/src/cleaner/storageProviders/iStorageProvider.ts b/src/cleaner/storageProviders/iStorageProvider.ts index c9cbead..e392459 100644 --- a/src/cleaner/storageProviders/iStorageProvider.ts +++ b/src/cleaner/storageProviders/iStorageProvider.ts @@ -1,12 +1,22 @@ +/** + * A single failed deletion paired with a short reason string (e.g. 'ENOENT', + * 'AccessDenied', 'NoSuchKey'). + */ +export interface DeleteFailure { + path: string; + reason: string; +} + export interface IStorageProvider { /** * Deletes a batch of relative file paths within the given storage target. * - S3: storageTarget = bucket name; paths are object keys * - FS: storageTarget = base directory; full path = join(storageTarget, path) * - * Returns the paths that failed to delete. Treats "not found" as success (idempotent). + * Returns one entry per failed deletion. "Not found" is reported as a failure + * (with reason 'ENOENT' / 'NoSuchKey'). */ - delete: (paths: string[], storageTarget: string) => Promise; + delete: (paths: string[], storageTarget: string) => Promise; /** * Returns true if relativePath exists within storageTarget and contains data. diff --git a/src/cleaner/storageProviders/index.ts b/src/cleaner/storageProviders/index.ts index 9f82eee..54b39c1 100644 --- a/src/cleaner/storageProviders/index.ts +++ b/src/cleaner/storageProviders/index.ts @@ -1,3 +1,4 @@ -export type { IStorageProvider } from './iStorageProvider'; +export type { IStorageProvider, DeleteFailure } from './iStorageProvider'; export { S3StorageProvider } from './s3StorageProvider'; export { FsStorageProvider } from './fsStorageProvider'; +export { summarizeDeleteFailures, type DeleteFailureSummary } from './deleteFailureSummary'; diff --git a/src/cleaner/storageProviders/s3StorageProvider.ts b/src/cleaner/storageProviders/s3StorageProvider.ts index 6c0da2e..d554a20 100644 --- a/src/cleaner/storageProviders/s3StorageProvider.ts +++ b/src/cleaner/storageProviders/s3StorageProvider.ts @@ -2,7 +2,8 @@ import { S3Client, DeleteObjectsCommand, ListObjectsV2Command, NoSuchBucket } from '@aws-sdk/client-s3'; import type { Logger } from '@map-colonies/js-logger'; import type { ConfigType } from '@common/config'; -import type { IStorageProvider } from './iStorageProvider'; +import { describeError } from '../errors'; +import type { DeleteFailure, IStorageProvider } from './iStorageProvider'; const S3_MAX_DELETE_BATCH = 1000; @@ -35,26 +36,29 @@ export class S3StorageProvider implements IStorageProvider { }); } - public async delete(paths: string[], storageTarget: string): Promise { + public async delete(paths: string[], storageTarget: string): Promise { if (paths.length === 0) { return []; } this.logger.debug({ msg: 'Deleting objects from S3', bucket: storageTarget, count: paths.length }); - const failedPaths: string[] = []; + const failures: DeleteFailure[] = []; for (const chunk of this.chunk(paths, S3_MAX_DELETE_BATCH)) { try { const failed = await this.deleteChunk(chunk, storageTarget); - failedPaths.push(...failed); + failures.push(...failed); } catch (error) { - this.logger.error({ msg: 'S3 batch request failed', bucket: storageTarget, error }); - failedPaths.push(...chunk); + // Whole chunk failed (network/auth/etc) — mark every path in it with the same reason + // so the caller still gets a per-path failure list and a human-readable cause. + const reason = describeError(error); + this.logger.error({ msg: 'S3 batch request failed', bucket: storageTarget, reason, error }); + failures.push(...chunk.map((path) => ({ path, reason }))); } } - return failedPaths; + return failures; } public async targetExists(bucket: string, relativePath: string): Promise { @@ -69,14 +73,16 @@ export class S3StorageProvider implements IStorageProvider { } } - private async deleteChunk(paths: string[], bucket: string): Promise { + private async deleteChunk(paths: string[], bucket: string): Promise { const command = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: paths.map((Key) => ({ Key })) }, }); const response = await this.s3Client.send(command); - return (response.Errors ?? []).map((e) => e.Key ?? '').filter(Boolean); // filter out any empty keys just in case, though they shouldn't occur + return (response.Errors ?? []) + .filter((e): e is typeof e & { Key: string } => Boolean(e.Key)) // Only include entries with a Key so every failure maps to a specific path. + .map((e) => ({ path: e.Key, reason: e.Code ?? e.Message ?? 'Unknown' })); } private *chunk(paths: string[], size: number): Generator { diff --git a/src/cleaner/strategies/tilesDeletionStrategy.ts b/src/cleaner/strategies/tilesDeletionStrategy.ts index b20e9b4..1b58c17 100644 --- a/src/cleaner/strategies/tilesDeletionStrategy.ts +++ b/src/cleaner/strategies/tilesDeletionStrategy.ts @@ -5,8 +5,8 @@ import type { TaskHandler as QueueClient } from '@map-colonies/mc-priority-queue import { PERCENTAGE_COMPLETE, SERVICES } from '@common/constants'; import type { ConfigType } from '@common/config'; import { validateSchema } from '../utils'; -import { RecoverableError, UnrecoverableError } from '../errors'; -import type { IStorageProvider } from '../storageProviders'; +import { RecoverableError, UnrecoverableError, describeError } from '../errors'; +import { summarizeDeleteFailures, type DeleteFailure, type IStorageProvider } from '../storageProviders'; import type { TaskContext } from './strategyFactory'; import type { ITaskStrategy } from './taskStrategy'; @@ -53,18 +53,19 @@ export class TilesDeletionStrategy implements ITaskStrategy totalTiles, }); - const failedPaths = await this.deleteAllTiles(provider, storageTarget, params, totalTiles); + const failures = await this.deleteAllTiles(provider, storageTarget, params, totalTiles); - if (failedPaths.length > 0) { - const sample = failedPaths.slice(0, this.failureSampleSize); + if (failures.length > 0) { + const { counts, summary, sample } = summarizeDeleteFailures(failures, this.failureSampleSize); this.logger.error({ msg: 'Tiles deletion partially failed', totalTiles, - failedCount: failedPaths.length, - deletedCount: totalTiles - failedPaths.length, + failedCount: failures.length, + deletedCount: totalTiles - failures.length, + reasonCounts: counts, sample, }); - throw new RecoverableError(`Failed to delete ${failedPaths.length} tiles. Sample: ${sample.join(', ')}`); + throw new RecoverableError(`Failed to delete ${failures.length} tiles. Reasons: ${summary}. Sample: ${sample.join(', ')}`); } this.logger.info({ msg: 'Tiles deletion completed successfully', deletedCount: totalTiles }); @@ -84,9 +85,9 @@ export class TilesDeletionStrategy implements ITaskStrategy storageTarget: string, params: TilesDeletionParams, totalTiles: number - ): Promise { + ): Promise { const { jobId, taskId } = this.taskContext; - const failedPaths: string[] = []; + const failures: DeleteFailure[] = []; const pendingBatches: string[][] = []; let batch: string[] = []; let processedTiles = 0; @@ -97,10 +98,10 @@ export class TilesDeletionStrategy implements ITaskStrategy pendingBatches.push(batch); batch = []; if (pendingBatches.length === this.concurrency) { - processedTiles += await this.flushBatches(provider, storageTarget, pendingBatches, failedPaths); + processedTiles += await this.flushBatches(provider, storageTarget, pendingBatches, failures); const percentage = Math.round((processedTiles / totalTiles) * PERCENTAGE_COMPLETE); await this.queueClient.updateProgress(jobId, taskId, percentage); - this.logger.info({ msg: 'Tiles deletion progress', deletionProgress: `${processedTiles}/${totalTiles}`, failedTiles: failedPaths.length }); + this.logger.info({ msg: 'Tiles deletion progress', deletionProgress: `${processedTiles}/${totalTiles}`, failedTiles: failures.length }); } } } @@ -109,33 +110,43 @@ export class TilesDeletionStrategy implements ITaskStrategy pendingBatches.push(batch); } if (pendingBatches.length > 0) { - await this.flushBatches(provider, storageTarget, pendingBatches, failedPaths); - this.logger.info({ msg: 'Tiles deletion progress', deletionProgress: `${processedTiles}/${totalTiles}`, failedTiles: failedPaths.length }); - if (failedPaths.length === 0) { + await this.flushBatches(provider, storageTarget, pendingBatches, failures); + this.logger.info({ msg: 'Tiles deletion progress', deletionProgress: `${processedTiles}/${totalTiles}`, failedTiles: failures.length }); + if (failures.length === 0) { await this.queueClient.updateProgress(jobId, taskId, PERCENTAGE_COMPLETE); } } - return failedPaths; + return failures; } /** * Deletes all pending batches concurrently via Promise.allSettled. - * Soft failures (paths returned by provider.delete) and hard failures (rejected promises) - * are both collected into failedPaths; hard-failed batch paths are added by index so nothing - * is silently lost. pendingBatches is cleared in-place for reuse. + * Soft failures (entries returned by provider.delete with reason) and hard failures + * (rejected promises — expanded into per-path failures with the thrown error as + * the reason) are both collected so nothing is silently lost and every failed path + * carries a cause the caller can surface in the task rejection reason. + * pendingBatches is cleared in-place for reuse. * * @returns Total tile paths attempted (not necessarily deleted). */ - private async flushBatches(provider: IStorageProvider, storageTarget: string, pendingBatches: string[][], failedPaths: string[]): Promise { + private async flushBatches( + provider: IStorageProvider, + storageTarget: string, + pendingBatches: string[][], + failures: DeleteFailure[] + ): Promise { const flushedCount = pendingBatches.reduce((sum, b) => sum + b.length, 0); const results = await Promise.allSettled(pendingBatches.map(async (batch) => provider.delete(batch, storageTarget))); for (const [index, result] of results.entries()) { if (result.status === 'fulfilled') { - failedPaths.push(...result.value); + failures.push(...result.value); } else { - this.logger.error({ msg: 'Batch delete threw unexpectedly', error: result.reason }); - failedPaths.push(...(pendingBatches[index] ?? [])); + const error: unknown = result.reason; + const reason = describeError(error); + this.logger.error({ msg: 'Batch delete threw unexpectedly', reason, error }); + const batch = pendingBatches[index] ?? []; + failures.push(...batch.map((path) => ({ path, reason }))); } } pendingBatches.length = 0; diff --git a/tests/storageProviders/deleteFailureSummary.spec.ts b/tests/storageProviders/deleteFailureSummary.spec.ts new file mode 100644 index 0000000..db128ef --- /dev/null +++ b/tests/storageProviders/deleteFailureSummary.spec.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { describe, it, expect } from 'vitest'; +import { summarizeDeleteFailures } from '@src/cleaner/storageProviders'; +import type { DeleteFailure } from '@src/cleaner/storageProviders'; + +describe('summarizeDeleteFailures', () => { + it('should return zero counts, empty summary and empty sample for an empty input', () => { + const result = summarizeDeleteFailures([], 5); + expect(result).toEqual({ counts: {}, summary: '', sample: [] }); + }); + + it('should count occurrences per reason', () => { + const failures: DeleteFailure[] = [ + { path: 'a', reason: 'ENOENT' }, + { path: 'b', reason: 'ENOENT' }, + { path: 'c', reason: 'EACCES' }, + ]; + + const { counts } = summarizeDeleteFailures(failures, 3); + + expect(counts).toEqual({ ENOENT: 2, EACCES: 1 }); + }); + + it('should format the summary with reasons sorted by descending count', () => { + const failures: DeleteFailure[] = [ + { path: 'a', reason: 'EACCES' }, + { path: 'b', reason: 'ENOENT' }, + { path: 'c', reason: 'ENOENT' }, + { path: 'd', reason: 'ENOENT' }, + ]; + + const { summary } = summarizeDeleteFailures(failures, 3); + + expect(summary).toBe('ENOENT=3, EACCES=1'); + }); + + it('should annotate each sampled path with its reason', () => { + const failures: DeleteFailure[] = [ + { path: 'tile/1.png', reason: 'ENOENT' }, + { path: 'tile/2.png', reason: 'EACCES' }, + ]; + + const { sample } = summarizeDeleteFailures(failures, 3); + + expect(sample).toEqual(['tile/1.png (ENOENT)', 'tile/2.png (EACCES)']); + }); + + it('should cap the sample to sampleSize and preserve input order', () => { + const failures: DeleteFailure[] = [ + { path: 'a', reason: 'ENOENT' }, + { path: 'b', reason: 'ENOENT' }, + { path: 'c', reason: 'ENOENT' }, + { path: 'd', reason: 'ENOENT' }, + ]; + + const { sample } = summarizeDeleteFailures(failures, 2); + + expect(sample).toEqual(['a (ENOENT)', 'b (ENOENT)']); + }); + + it('should return all failures in the sample when sampleSize exceeds input length', () => { + const failures: DeleteFailure[] = [{ path: 'a', reason: 'ENOENT' }]; + + const { sample } = summarizeDeleteFailures(failures, 10); + + expect(sample).toEqual(['a (ENOENT)']); + }); + + it('should return an empty sample when sampleSize is 0', () => { + const failures: DeleteFailure[] = [{ path: 'a', reason: 'ENOENT' }]; + + const { sample } = summarizeDeleteFailures(failures, 0); + + expect(sample).toEqual([]); + }); +}); diff --git a/tests/storageProviders/fsStorageProvider.spec.ts b/tests/storageProviders/fsStorageProvider.spec.ts index f824601..41756bc 100644 --- a/tests/storageProviders/fsStorageProvider.spec.ts +++ b/tests/storageProviders/fsStorageProvider.spec.ts @@ -79,22 +79,30 @@ describe('FsStorageProvider', () => { expect(result).toEqual([]); }); - it('should treat ENOENT as a failed deletion (included in failure report)', async () => { + it('should treat ENOENT as a failed deletion tagged with ENOENT reason', async () => { const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); vi.mocked(unlink).mockRejectedValue(enoent); const result = await provider.delete(['tile/10/0/0.png'], BASE_PATH); - expect(result).toEqual(['tile/10/0/0.png']); + expect(result).toEqual([{ path: 'tile/10/0/0.png', reason: 'ENOENT' }]); }); - it('should return failed path for non-ENOENT errors', async () => { + it('should return failed path with reason for non-ENOENT errors', async () => { const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); vi.mocked(unlink).mockRejectedValue(permError); const result = await provider.delete(['tile/10/0/0.png'], BASE_PATH); - expect(result).toEqual(['tile/10/0/0.png']); + expect(result).toEqual([{ path: 'tile/10/0/0.png', reason: 'EACCES' }]); + }); + + it('should fall back to error message when error has no errno code', async () => { + vi.mocked(unlink).mockRejectedValue(new Error('disk on fire')); + + const result = await provider.delete(['tile/10/0/0.png'], BASE_PATH); + + expect(result).toEqual([{ path: 'tile/10/0/0.png', reason: 'disk on fire' }]); }); it('should handle mixed success, ENOENT and real errors', async () => { @@ -109,18 +117,21 @@ describe('FsStorageProvider', () => { const paths = ['tile/10/0/0.png', 'tile/10/0/1.png', 'tile/10/0/2.png']; const result = await provider.delete(paths, BASE_PATH); - expect(result).toEqual(['tile/10/0/1.png', 'tile/10/0/2.png']); + expect(result).toEqual([ + { path: 'tile/10/0/1.png', reason: 'ENOENT' }, + { path: 'tile/10/0/2.png', reason: 'EACCES' }, + ]); }); - it('should use relative path as key in failed paths (not the full absolute path)', async () => { + it('should use relative path (not the full absolute path) in failure entries', async () => { const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); vi.mocked(unlink).mockRejectedValue(permError); const relativePath = 'layer/v1/10/5/3.png'; const result = await provider.delete([relativePath], BASE_PATH); - expect(result).toEqual([relativePath]); - expect(result[0]).not.toContain(BASE_PATH); + expect(result).toEqual([{ path: relativePath, reason: 'EACCES' }]); + expect(result[0]!.path).not.toContain(BASE_PATH); }); describe('cleanupEmptyDirs', () => { diff --git a/tests/storageProviders/s3StorageProvider.spec.ts b/tests/storageProviders/s3StorageProvider.spec.ts index b9001cc..028129d 100644 --- a/tests/storageProviders/s3StorageProvider.spec.ts +++ b/tests/storageProviders/s3StorageProvider.spec.ts @@ -59,7 +59,7 @@ describe('S3StorageProvider', () => { expect(result).toEqual([]); }); - it('should return failed paths from response.Errors', async () => { + it('should return failed paths tagged with the S3 error Code', async () => { mockSend.mockResolvedValue({ Errors: [{ Key: 'a.txt', Code: 'AccessDenied', Message: 'Forbidden' }], }); @@ -67,10 +67,10 @@ describe('S3StorageProvider', () => { const result = await provider.delete(paths, BUCKET); - expect(result).toEqual(['a.txt']); + expect(result).toEqual([{ path: 'a.txt', reason: 'AccessDenied' }]); }); - it('should treat NoSuchKey as a failed deletion (included in failure report)', async () => { + it('should treat NoSuchKey as a failed deletion tagged with NoSuchKey reason', async () => { mockSend.mockResolvedValue({ Errors: [{ Key: 'missing.txt', Code: 'NoSuchKey', Message: 'Not Found' }], }); @@ -78,10 +78,20 @@ describe('S3StorageProvider', () => { const result = await provider.delete(paths, BUCKET); - expect(result).toEqual(['missing.txt']); + expect(result).toEqual([{ path: 'missing.txt', reason: 'NoSuchKey' }]); }); - it('should return all errors including NoSuchKey', async () => { + it('should fall back to Message when error has no Code', async () => { + mockSend.mockResolvedValue({ + Errors: [{ Key: 'a.txt', Message: 'Something bad' }], + }); + + const result = await provider.delete(['a.txt'], BUCKET); + + expect(result).toEqual([{ path: 'a.txt', reason: 'Something bad' }]); + }); + + it('should return all errors including NoSuchKey with their codes', async () => { mockSend.mockResolvedValue({ Errors: [ { Key: 'a.txt', Code: 'NoSuchKey' }, @@ -93,7 +103,12 @@ describe('S3StorageProvider', () => { const result = await provider.delete(['a.txt', 'b.txt', 'c.txt', 'd.txt'], BUCKET); - expect(result).toEqual(['a.txt', 'b.txt', 'c.txt', 'd.txt']); + expect(result).toEqual([ + { path: 'a.txt', reason: 'NoSuchKey' }, + { path: 'b.txt', reason: 'AccessDenied' }, + { path: 'c.txt', reason: 'NoSuchKey' }, + { path: 'd.txt', reason: 'InternalError' }, + ]); }); it('should batch paths into chunks of 1000 (S3 limit)', async () => { @@ -120,16 +135,22 @@ describe('S3StorageProvider', () => { const result = await provider.delete(paths, BUCKET); - expect(result).toEqual(['object-0.txt', 'object-1000.txt']); + expect(result).toEqual([ + { path: 'object-0.txt', reason: 'AccessDenied' }, + { path: 'object-1000.txt', reason: 'AccessDenied' }, + ]); }); - it('should add entire chunk to failed paths when send throws', async () => { + it('should add entire chunk to failures tagged with the thrown error when send rejects', async () => { mockSend.mockRejectedValue(new Error('Network error')); const paths = ['a.txt', 'b.txt']; const result = await provider.delete(paths, BUCKET); - expect(result).toEqual(paths); + expect(result).toEqual([ + { path: 'a.txt', reason: 'Network error' }, + { path: 'b.txt', reason: 'Network error' }, + ]); }); }); diff --git a/tests/tilesDeletionStrategy.spec.ts b/tests/tilesDeletionStrategy.spec.ts index b2d0c26..69b1c5d 100644 --- a/tests/tilesDeletionStrategy.spec.ts +++ b/tests/tilesDeletionStrategy.spec.ts @@ -214,7 +214,7 @@ describe('TilesDeletionStrategy', () => { }); it('should not call updateProgress with 100 when the final flush has failures', async () => { - vi.mocked(MockS3Provider.delete).mockResolvedValue([tilePath(10, 0, 0)]); + vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); await expect(strategy.execute(s3Params)).rejects.toThrow(RecoverableError); @@ -224,15 +224,34 @@ describe('TilesDeletionStrategy', () => { describe('failure handling', () => { it('should throw RecoverableError when provider returns failed paths', async () => { - vi.mocked(MockS3Provider.delete).mockResolvedValue([tilePath(10, 0, 0)]); + vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); await expect(strategy.execute(s3Params)).rejects.toThrow(RecoverableError); }); it('should include failed count in RecoverableError message', async () => { - vi.mocked(MockS3Provider.delete).mockResolvedValue([tilePath(10, 0, 0), tilePath(10, 0, 1)]); + vi.mocked(MockS3Provider.delete).mockResolvedValue([ + { path: tilePath(10, 0, 0), reason: 'NoSuchKey' }, + { path: tilePath(10, 0, 1), reason: 'NoSuchKey' }, + ]); - await expect(strategy.execute(s3Params)).rejects.toThrow(/2/); + await expect(strategy.execute(s3Params)).rejects.toThrow(/Failed to delete 2/); + }); + + it('should include grouped reason counts in RecoverableError message', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([ + { path: tilePath(10, 0, 0), reason: 'NoSuchKey' }, + { path: tilePath(10, 0, 1), reason: 'NoSuchKey' }, + { path: tilePath(10, 1, 0), reason: 'AccessDenied' }, + ]); + + await expect(strategy.execute(s3Params)).rejects.toThrow(/Reasons: NoSuchKey=2, AccessDenied=1/); + }); + + it('should include path and reason in the failure sample', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); + + await expect(strategy.execute(s3Params)).rejects.toThrow(/layer\/v1\/10\/0\/0\.png \(NoSuchKey\)/); }); it('should resolve successfully when provider returns no failed paths', async () => { @@ -253,6 +272,12 @@ describe('TilesDeletionStrategy', () => { await expect(strategy.execute(s3Params)).rejects.toThrow(/Failed to delete 4/); }); + + it('should tag hard-rejected batch failures with the thrown error message as reason', async () => { + vi.mocked(MockS3Provider.delete).mockRejectedValue(new Error('S3 connection lost')); + + await expect(strategy.execute(s3Params)).rejects.toThrow(/S3 connection lost/); + }); }); }); }); From 0402bedd1dca1ecab951225c3d1d75480d3c1c6c Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 16:47:38 +0300 Subject: [PATCH 3/9] fix: improve handling of deletion outcomes and refine error reporting for retryable failures --- .../strategies/tilesDeletionStrategy.ts | 48 +++++++++++--- tests/tilesDeletionStrategy.spec.ts | 64 +++++++++++++++---- 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/cleaner/strategies/tilesDeletionStrategy.ts b/src/cleaner/strategies/tilesDeletionStrategy.ts index 1b58c17..ba130f5 100644 --- a/src/cleaner/strategies/tilesDeletionStrategy.ts +++ b/src/cleaner/strategies/tilesDeletionStrategy.ts @@ -2,6 +2,7 @@ import { inject, injectable } from 'tsyringe'; import type { Logger } from '@map-colonies/js-logger'; import { SourceType, TileRange, TilesDeletionParams, tilesDeletionParamsSchema } from '@map-colonies/raster-shared'; import type { TaskHandler as QueueClient } from '@map-colonies/mc-priority-queue'; +import { NoSuchKey } from '@aws-sdk/client-s3'; import { PERCENTAGE_COMPLETE, SERVICES } from '@common/constants'; import type { ConfigType } from '@common/config'; import { validateSchema } from '../utils'; @@ -10,6 +11,8 @@ import { summarizeDeleteFailures, type DeleteFailure, type IStorageProvider } fr import type { TaskContext } from './strategyFactory'; import type { ITaskStrategy } from './taskStrategy'; +const NOT_FOUND_REASONS = new Set([NoSuchKey.name, 'ENOENT']); + @injectable() export class TilesDeletionStrategy implements ITaskStrategy { private readonly batchSize: number; @@ -54,18 +57,50 @@ export class TilesDeletionStrategy implements ITaskStrategy }); const failures = await this.deleteAllTiles(provider, storageTarget, params, totalTiles); + await this.reportOutcome(failures, totalTiles); + } + + /** + * Decides whether the deletion run succeeded, succeeded-with-missing-tiles, or + * must be retried, and logs at the appropriate level. Not-found failures are + * treated as success (deletion is idempotent — a tile that's already gone + * matches the desired end-state) but counted separately for visibility. + * Owns the terminal-progress update — success branches advance the queue to 100%. + */ + private async reportOutcome(failures: DeleteFailure[], totalTiles: number): Promise { + const retryable: DeleteFailure[] = []; + const notFound: DeleteFailure[] = []; + for (const failure of failures) { + (NOT_FOUND_REASONS.has(failure.reason) ? notFound : retryable).push(failure); + } + + const deletedCount = totalTiles - retryable.length - notFound.length; - if (failures.length > 0) { - const { counts, summary, sample } = summarizeDeleteFailures(failures, this.failureSampleSize); + if (retryable.length > 0) { + const { counts, summary, sample } = summarizeDeleteFailures(retryable, this.failureSampleSize); this.logger.error({ msg: 'Tiles deletion partially failed', totalTiles, - failedCount: failures.length, - deletedCount: totalTiles - failures.length, + failedCount: retryable.length, + notFoundCount: notFound.length, + deletedCount, reasonCounts: counts, sample, }); - throw new RecoverableError(`Failed to delete ${failures.length} tiles. Reasons: ${summary}. Sample: ${sample.join(', ')}`); + throw new RecoverableError(`Failed to delete ${retryable.length} tiles. Reasons: ${summary}. Sample: ${sample.join(', ')}`); + } + + await this.queueClient.updateProgress(this.taskContext.jobId, this.taskContext.taskId, PERCENTAGE_COMPLETE); + + if (notFound.length > 0) { + this.logger.warn({ + msg: 'Tiles deletion completed with missing tiles', + totalTiles, + notFoundCount: notFound.length, + deletedCount, + allTilesMissing: notFound.length === totalTiles, + }); + return; } this.logger.info({ msg: 'Tiles deletion completed successfully', deletedCount: totalTiles }); @@ -112,9 +147,6 @@ export class TilesDeletionStrategy implements ITaskStrategy if (pendingBatches.length > 0) { await this.flushBatches(provider, storageTarget, pendingBatches, failures); this.logger.info({ msg: 'Tiles deletion progress', deletionProgress: `${processedTiles}/${totalTiles}`, failedTiles: failures.length }); - if (failures.length === 0) { - await this.queueClient.updateProgress(jobId, taskId, PERCENTAGE_COMPLETE); - } } return failures; diff --git a/tests/tilesDeletionStrategy.spec.ts b/tests/tilesDeletionStrategy.spec.ts index 69b1c5d..a670916 100644 --- a/tests/tilesDeletionStrategy.spec.ts +++ b/tests/tilesDeletionStrategy.spec.ts @@ -213,26 +213,44 @@ describe('TilesDeletionStrategy', () => { expect(mockUpdateProgress).toHaveBeenCalledWith(JOB_ID, TASK_ID, expect.any(Number)); }); - it('should not call updateProgress with 100 when the final flush has failures', async () => { - vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); + it('should not call updateProgress with 100% when retryable failures occur', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'AccessDenied' }]); await expect(strategy.execute(s3Params)).rejects.toThrow(RecoverableError); expect(mockUpdateProgress).not.toHaveBeenCalledWith(JOB_ID, TASK_ID, 100); }); + + it('should not call updateProgress with 100% when retryable and not-found failures are mixed', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([ + { path: tilePath(10, 0, 0), reason: 'NoSuchKey' }, + { path: tilePath(10, 0, 1), reason: 'AccessDenied' }, + ]); + + await expect(strategy.execute(s3Params)).rejects.toThrow(RecoverableError); + + expect(mockUpdateProgress).not.toHaveBeenCalledWith(JOB_ID, TASK_ID, 100); + }); + + it('should call updateProgress with 100% when not-found failures occur', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); + + await expect(strategy.execute(s3Params)).resolves.toBeUndefined(); + expect(mockUpdateProgress).toHaveBeenLastCalledWith(JOB_ID, TASK_ID, 100); + }); }); describe('failure handling', () => { - it('should throw RecoverableError when provider returns failed paths', async () => { - vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); + it('should throw RecoverableError when provider returns fatal failed paths', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'AccessDenied' }]); await expect(strategy.execute(s3Params)).rejects.toThrow(RecoverableError); }); - it('should include failed count in RecoverableError message', async () => { + it('should include fatal failed count in RecoverableError message', async () => { vi.mocked(MockS3Provider.delete).mockResolvedValue([ - { path: tilePath(10, 0, 0), reason: 'NoSuchKey' }, - { path: tilePath(10, 0, 1), reason: 'NoSuchKey' }, + { path: tilePath(10, 0, 0), reason: 'AccessDenied' }, + { path: tilePath(10, 0, 1), reason: 'AccessDenied' }, ]); await expect(strategy.execute(s3Params)).rejects.toThrow(/Failed to delete 2/); @@ -240,18 +258,42 @@ describe('TilesDeletionStrategy', () => { it('should include grouped reason counts in RecoverableError message', async () => { vi.mocked(MockS3Provider.delete).mockResolvedValue([ - { path: tilePath(10, 0, 0), reason: 'NoSuchKey' }, - { path: tilePath(10, 0, 1), reason: 'NoSuchKey' }, + { path: tilePath(10, 0, 0), reason: 'EACCES' }, + { path: tilePath(10, 0, 1), reason: 'EACCES' }, { path: tilePath(10, 1, 0), reason: 'AccessDenied' }, ]); - await expect(strategy.execute(s3Params)).rejects.toThrow(/Reasons: NoSuchKey=2, AccessDenied=1/); + await expect(strategy.execute(s3Params)).rejects.toThrow(/Reasons: EACCES=2, AccessDenied=1/); + }); + + it('should exclude not-found reasons from the RecoverableError reason summary', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([ + { path: tilePath(10, 0, 0), reason: 'NoSuchKey' }, + { path: tilePath(10, 0, 1), reason: 'AccessDenied' }, + ]); + + await expect(strategy.execute(s3Params)).rejects.toThrow(/Failed to delete 1.*Reasons: AccessDenied=1/); }); it('should include path and reason in the failure sample', async () => { + vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'AccessDenied' }]); + + await expect(strategy.execute(s3Params)).rejects.toThrow(/layer\/v1\/10\/0\/0\.png \(AccessDenied\)/); + }); + + it('should resolve successfully when all failures are not-found (ENOENT)', async () => { + vi.mocked(MockFsProvider.delete).mockResolvedValue([ + { path: tilePath(10, 0, 0), reason: 'ENOENT' }, + { path: tilePath(10, 0, 1), reason: 'ENOENT' }, + ]); + + await expect(strategy.execute(fsParams)).resolves.toBeUndefined(); + }); + + it('should resolve successfully when all failures are not-found (NoSuchKey)', async () => { vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); - await expect(strategy.execute(s3Params)).rejects.toThrow(/layer\/v1\/10\/0\/0\.png \(NoSuchKey\)/); + await expect(strategy.execute(s3Params)).resolves.toBeUndefined(); }); it('should resolve successfully when provider returns no failed paths', async () => { From 7e311c064d5315fe2bf8a6521612d60cc3e7c25f Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 16:51:34 +0300 Subject: [PATCH 4/9] fix: update sample expectation to validate length instead of specific values --- tests/storageProviders/deleteFailureSummary.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/storageProviders/deleteFailureSummary.spec.ts b/tests/storageProviders/deleteFailureSummary.spec.ts index db128ef..97d28c1 100644 --- a/tests/storageProviders/deleteFailureSummary.spec.ts +++ b/tests/storageProviders/deleteFailureSummary.spec.ts @@ -55,7 +55,7 @@ describe('summarizeDeleteFailures', () => { const { sample } = summarizeDeleteFailures(failures, 2); - expect(sample).toEqual(['a (ENOENT)', 'b (ENOENT)']); + expect(sample).length(2); }); it('should return all failures in the sample when sampleSize exceeds input length', () => { From 9c75d239bd1498e42afdc99c959e68db8b3dd4b8 Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 16:52:23 +0300 Subject: [PATCH 5/9] fix: correct sample output format in summarizeDeleteFailures test --- tests/storageProviders/deleteFailureSummary.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/storageProviders/deleteFailureSummary.spec.ts b/tests/storageProviders/deleteFailureSummary.spec.ts index 97d28c1..6f669cc 100644 --- a/tests/storageProviders/deleteFailureSummary.spec.ts +++ b/tests/storageProviders/deleteFailureSummary.spec.ts @@ -55,6 +55,7 @@ describe('summarizeDeleteFailures', () => { const { sample } = summarizeDeleteFailures(failures, 2); + expect(sample).toEqual(['a (ENOENT)', 'b (ENOENT)']); expect(sample).length(2); }); From a6d6780bed41c4b275aec29867c0ae7f6685304c Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 17:02:23 +0300 Subject: [PATCH 6/9] test: check errors fallback to unknown when code or message doesn't exist --- src/cleaner/errors/errors.ts | 6 ++++-- tests/storageProviders/fsStorageProvider.spec.ts | 8 ++++++++ tests/storageProviders/s3StorageProvider.spec.ts | 10 ++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/cleaner/errors/errors.ts b/src/cleaner/errors/errors.ts index bbcdd2e..bec92f1 100644 --- a/src/cleaner/errors/errors.ts +++ b/src/cleaner/errors/errors.ts @@ -22,10 +22,12 @@ export function toError(value: unknown): Error { export function describeError(value: unknown): string { if (value instanceof Error) { const code = (value as NodeJS.ErrnoException).code; - return code ?? value.message; + // value.message is always a string on Error (default ''), so `??` would never reach the fallback — + // use `||` here to also skip the empty-string case. + return code ?? (value.message || 'Unknown'); } try { - return String(value); + return String(value) || 'Unknown'; } catch { return 'non-serializable thrown value'; } diff --git a/tests/storageProviders/fsStorageProvider.spec.ts b/tests/storageProviders/fsStorageProvider.spec.ts index 41756bc..cdca3f4 100644 --- a/tests/storageProviders/fsStorageProvider.spec.ts +++ b/tests/storageProviders/fsStorageProvider.spec.ts @@ -105,6 +105,14 @@ describe('FsStorageProvider', () => { expect(result).toEqual([{ path: 'tile/10/0/0.png', reason: 'disk on fire' }]); }); + it('should fall back to "Unknown" when error has neither errno code nor message', async () => { + vi.mocked(unlink).mockRejectedValue(new Error('')); + + const result = await provider.delete(['tile/10/0/0.png'], BASE_PATH); + + expect(result).toEqual([{ path: 'tile/10/0/0.png', reason: 'Unknown' }]); + }); + it('should handle mixed success, ENOENT and real errors', async () => { const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); diff --git a/tests/storageProviders/s3StorageProvider.spec.ts b/tests/storageProviders/s3StorageProvider.spec.ts index 028129d..1dde516 100644 --- a/tests/storageProviders/s3StorageProvider.spec.ts +++ b/tests/storageProviders/s3StorageProvider.spec.ts @@ -91,6 +91,16 @@ describe('S3StorageProvider', () => { expect(result).toEqual([{ path: 'a.txt', reason: 'Something bad' }]); }); + it('should fall back to "Unknown" when error has neither Code nor Message', async () => { + mockSend.mockResolvedValue({ + Errors: [{ Key: 'a.txt' }], + }); + + const result = await provider.delete(['a.txt'], BUCKET); + + expect(result).toEqual([{ path: 'a.txt', reason: 'Unknown' }]); + }); + it('should return all errors including NoSuchKey with their codes', async () => { mockSend.mockResolvedValue({ Errors: [ From 980dad7f3e69ef506f67b0da0806c6dfb4c7bd26 Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 17:03:56 +0300 Subject: [PATCH 7/9] fix: refine error description logic to handle empty message cases --- src/cleaner/errors/errors.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cleaner/errors/errors.ts b/src/cleaner/errors/errors.ts index bec92f1..a3efe73 100644 --- a/src/cleaner/errors/errors.ts +++ b/src/cleaner/errors/errors.ts @@ -22,8 +22,6 @@ export function toError(value: unknown): Error { export function describeError(value: unknown): string { if (value instanceof Error) { const code = (value as NodeJS.ErrnoException).code; - // value.message is always a string on Error (default ''), so `??` would never reach the fallback — - // use `||` here to also skip the empty-string case. return code ?? (value.message || 'Unknown'); } try { From ae7bd3b85aababfb4d57d21051e1b919a2bb0ef5 Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 17:26:36 +0300 Subject: [PATCH 8/9] fix: update progress reporting logic to remove unnecessary 100% completion calls --- .../strategies/tilesDeletionStrategy.ts | 8 ++-- tests/tilesDeletionStrategy.spec.ts | 40 +++++++------------ 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/src/cleaner/strategies/tilesDeletionStrategy.ts b/src/cleaner/strategies/tilesDeletionStrategy.ts index ba130f5..c0b1b6c 100644 --- a/src/cleaner/strategies/tilesDeletionStrategy.ts +++ b/src/cleaner/strategies/tilesDeletionStrategy.ts @@ -57,7 +57,7 @@ export class TilesDeletionStrategy implements ITaskStrategy }); const failures = await this.deleteAllTiles(provider, storageTarget, params, totalTiles); - await this.reportOutcome(failures, totalTiles); + this.reportOutcome(failures, totalTiles); } /** @@ -65,9 +65,9 @@ export class TilesDeletionStrategy implements ITaskStrategy * must be retried, and logs at the appropriate level. Not-found failures are * treated as success (deletion is idempotent — a tile that's already gone * matches the desired end-state) but counted separately for visibility. - * Owns the terminal-progress update — success branches advance the queue to 100%. + * Terminal progress to 100% is handled by the queue's task-ack — no explicit call needed here. */ - private async reportOutcome(failures: DeleteFailure[], totalTiles: number): Promise { + private reportOutcome(failures: DeleteFailure[], totalTiles: number): void { const retryable: DeleteFailure[] = []; const notFound: DeleteFailure[] = []; for (const failure of failures) { @@ -90,8 +90,6 @@ export class TilesDeletionStrategy implements ITaskStrategy throw new RecoverableError(`Failed to delete ${retryable.length} tiles. Reasons: ${summary}. Sample: ${sample.join(', ')}`); } - await this.queueClient.updateProgress(this.taskContext.jobId, this.taskContext.taskId, PERCENTAGE_COMPLETE); - if (notFound.length > 0) { this.logger.warn({ msg: 'Tiles deletion completed with missing tiles', diff --git a/tests/tilesDeletionStrategy.spec.ts b/tests/tilesDeletionStrategy.spec.ts index a670916..7669a80 100644 --- a/tests/tilesDeletionStrategy.spec.ts +++ b/tests/tilesDeletionStrategy.spec.ts @@ -187,13 +187,7 @@ describe('TilesDeletionStrategy', () => { }); describe('progress reporting', () => { - it('should call updateProgress with 100 when tiles fit in a single flush', async () => { - await strategy.execute(s3Params); - - expect(mockUpdateProgress).toHaveBeenCalledWith(JOB_ID, TASK_ID, 100); - }); - - it('should call updateProgress mid-stream and at 100 for large tile sets', async () => { + it('should call updateProgress mid-stream for large tile sets without ever setting 100', async () => { // batchSize=100, concurrency=2 → flush after 200 tiles, then final flush for remainder // 14 * 15 = 210 tiles const params: TilesDeletionParams = { @@ -203,40 +197,34 @@ describe('TilesDeletionStrategy', () => { await strategy.execute(params); - expect(mockUpdateProgress).toHaveBeenCalledTimes(2); - expect(mockUpdateProgress).toHaveBeenLastCalledWith(JOB_ID, TASK_ID, 100); + expect(mockUpdateProgress).toHaveBeenCalledTimes(1); + expect(mockUpdateProgress).not.toHaveBeenCalledWith(JOB_ID, TASK_ID, 100); }); - it('should call updateProgress with the correct jobId and taskId', async () => { - await strategy.execute(s3Params); + it('should pass the correct jobId and taskId on mid-stream updates', async () => { + const params: TilesDeletionParams = { + ...s3Params, + ranges: [{ zoom: 5, minX: 0, maxX: 13, minY: 0, maxY: 14 }], + }; + + await strategy.execute(params); expect(mockUpdateProgress).toHaveBeenCalledWith(JOB_ID, TASK_ID, expect.any(Number)); }); - it('should not call updateProgress with 100% when retryable failures occur', async () => { + it('should not call updateProgress when retryable failures occur', async () => { vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'AccessDenied' }]); await expect(strategy.execute(s3Params)).rejects.toThrow(RecoverableError); - expect(mockUpdateProgress).not.toHaveBeenCalledWith(JOB_ID, TASK_ID, 100); - }); - - it('should not call updateProgress with 100% when retryable and not-found failures are mixed', async () => { - vi.mocked(MockS3Provider.delete).mockResolvedValue([ - { path: tilePath(10, 0, 0), reason: 'NoSuchKey' }, - { path: tilePath(10, 0, 1), reason: 'AccessDenied' }, - ]); - - await expect(strategy.execute(s3Params)).rejects.toThrow(RecoverableError); - - expect(mockUpdateProgress).not.toHaveBeenCalledWith(JOB_ID, TASK_ID, 100); + expect(mockUpdateProgress).not.toHaveBeenCalled(); }); - it('should call updateProgress with 100% when not-found failures occur', async () => { + it('should not call updateProgress when only not-found failures occur', async () => { vi.mocked(MockS3Provider.delete).mockResolvedValue([{ path: tilePath(10, 0, 0), reason: 'NoSuchKey' }]); await expect(strategy.execute(s3Params)).resolves.toBeUndefined(); - expect(mockUpdateProgress).toHaveBeenLastCalledWith(JOB_ID, TASK_ID, 100); + expect(mockUpdateProgress).not.toHaveBeenCalled(); }); }); From 7963556a7df08294b4115497dd26eb80a709b309 Mon Sep 17 00:00:00 2001 From: almog8k Date: Thu, 14 May 2026 18:15:42 +0300 Subject: [PATCH 9/9] feat: add JobTrackerClient to notify job tracker on task completion and failure - Added JobTrackerClient to handle notifications to the job tracker service. - Integrated JobTrackerClient into TaskPoller to notify upon task completion and rejection. - Updated constants to include JOB_TRACKER_CLIENT symbol. - Created unit tests for JobTrackerClient to ensure proper notification behavior. - Enhanced TaskPoller tests to verify interactions with JobTrackerClient. - Updated package.json to include @map-colonies/mc-utils dependency. --- config/custom-environment-variables.json | 7 + config/default.json | 4 + helm/templates/configmap.yaml | 2 + helm/values.yaml | 2 + package-lock.json | 194 ++------------------ package.json | 1 + src/cleaner/httpClients/index.ts | 1 + src/cleaner/httpClients/jobTrackerClient.ts | 29 +++ src/common/constants.ts | 1 + src/containerConfig.ts | 7 + src/worker/taskPoller.ts | 14 +- tests/helpers/mocks.ts | 11 +- tests/httpClients/jobTrackerClient.spec.ts | 68 +++++++ tests/taskPoller.spec.ts | 38 +++- 14 files changed, 192 insertions(+), 187 deletions(-) create mode 100644 src/cleaner/httpClients/index.ts create mode 100644 src/cleaner/httpClients/jobTrackerClient.ts create mode 100644 tests/httpClients/jobTrackerClient.spec.ts diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index c189508..31a6d8c 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -51,6 +51,13 @@ "__format": "number" } }, + "servicesUrl": { + "jobTracker": "SERVICES_URL_JOB_TRACKER" + }, + "disableHttpClientLogs": { + "__name": "DISABLE_HTTP_CLIENT_LOGS", + "__format": "boolean" + }, "jobDefinitions": { "__name": "JOB_DEFINITIONS", "__format": "json" diff --git a/config/default.json b/config/default.json index 4463b6a..901d821 100644 --- a/config/default.json +++ b/config/default.json @@ -38,6 +38,10 @@ "heartbeatIntervalMs": 1000, "dequeueIntervalMs": 3000 }, + "servicesUrl": { + "jobTracker": "http://localhost:8082" + }, + "disableHttpClientLogs": false, "jobDefinitions": { "jobs": { "update": { diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index a5572a5..855dbaa 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -35,6 +35,8 @@ data: {{- end }} QUEUE_JOB_MANAGER_BASE_URL: {{ $serviceUrls.jobManager | default "" | quote }} QUEUE_HEARTBEAT_BASE_URL: {{ $serviceUrls.heartbeatManager | default "" | quote }} + SERVICES_URL_JOB_TRACKER: {{ $serviceUrls.jobTracker | default "" | quote }} + DISABLE_HTTP_CLIENT_LOGS: {{ .Values.env.disableHttpClientLogs | default false | quote }} {{- with .Values.env.queue }} QUEUE_HEARTBEAT_INTERVAL_MS: {{ .heartbeatIntervalMs | default 1000 | quote }} QUEUE_DEQUEUE_INTERVAL_MS: {{ .dequeueIntervalMs | default 3000 | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index 23a1633..be16b49 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -10,6 +10,7 @@ global: serviceUrls: jobManager: "" heartbeatManager: "" + jobTracker: "" storage: s3: @@ -121,6 +122,7 @@ env: attempts: 3 delay: "exponential" shouldResetTimeout: true + disableHttpClientLogs: false strategies: tilesDeletion: batchSize: 1000 diff --git a/package-lock.json b/package-lock.json index ddb93c4..3406476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@map-colonies/jobnik-sdk": "^0.1.0", "@map-colonies/js-logger": "^5.0.0", "@map-colonies/mc-priority-queue": "^9.1.0", + "@map-colonies/mc-utils": "^5.1.0", "@map-colonies/raster-shared": "^8.1.0-alpha.3", "@map-colonies/read-pkg": "^1.0.0", "@map-colonies/schemas": "^1.20.0", @@ -2517,7 +2518,6 @@ "resolved": "https://registry.npmjs.org/@map-colonies/mc-utils/-/mc-utils-5.1.0.tgz", "integrity": "sha512-4eL4ykgJFrFSPP18AbeJUJklwmzJieormWIvEc8c8PFRXU5eN1lRUal60xAhghpmzEQnudRG1kV3NWIibUjw2w==", "license": "ISC", - "peer": true, "dependencies": { "@map-colonies/types": "^1.9.0", "@turf/turf": "^6.5.0", @@ -5375,7 +5375,6 @@ "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz", "integrity": "sha512-LLyWQ0AARqJCmMcIEAXF4GEu8usmd4Kbz3qk1Oy5HoRNpZX47+i5exQtmIWKdqJ1MMhW26fCTXgpsEs5zgJ5gw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bearing": "^6.5.0", "@turf/destination": "^6.5.0", @@ -5392,7 +5391,6 @@ "resolved": "https://registry.npmjs.org/@turf/angle/-/angle-6.5.0.tgz", "integrity": "sha512-4pXMbWhFofJJAOvTMCns6N4C8CMd5Ih4O2jSAG9b3dDHakj3O4yN1+Zbm+NUei+eVEZ9gFeVp9svE3aMDenIkw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bearing": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5408,7 +5406,6 @@ "resolved": "https://registry.npmjs.org/@turf/area/-/area-6.5.0.tgz", "integrity": "sha512-xCZdiuojokLbQ+29qR6qoMD89hv+JAgWjLrwSEWL+3JV8IXKeNFl6XkEJz9HGkVpnXvQKJoRz4/liT+8ZZ5Jyg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -5422,7 +5419,6 @@ "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -5436,7 +5432,6 @@ "resolved": "https://registry.npmjs.org/@turf/bbox-clip/-/bbox-clip-6.5.0.tgz", "integrity": "sha512-F6PaIRF8WMp8EmgU/Ke5B1Y6/pia14UAYB5TiBC668w5rVVjy5L8rTm/m2lEkkDMHlzoP9vNY4pxpNthE7rLcQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5450,7 +5445,6 @@ "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-6.5.0.tgz", "integrity": "sha512-+/r0NyL1lOG3zKZmmf6L8ommU07HliP4dgYToMoTxqzsWzyLjaj/OzgQ8rBmv703WJX+aS6yCmLuIhYqyufyuw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -5463,7 +5457,6 @@ "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz", "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5477,7 +5470,6 @@ "resolved": "https://registry.npmjs.org/@turf/bezier-spline/-/bezier-spline-6.5.0.tgz", "integrity": "sha512-vokPaurTd4PF96rRgGVm6zYYC5r1u98ZsG+wZEv9y3kJTuJRX/O3xIY2QnTGTdbVmAJN1ouOsD0RoZYaVoXORQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5491,7 +5483,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-6.5.0.tgz", "integrity": "sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5505,7 +5496,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz", "integrity": "sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -5522,7 +5512,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-crosses/-/boolean-crosses-6.5.0.tgz", "integrity": "sha512-gvshbTPhAHporTlQwBJqyfW+2yV8q/mOTxG6PzRVl6ARsqNoqYQWkd4MLug7OmAqVyBzLK3201uAeBjxbGw0Ng==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5539,7 +5528,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-6.5.0.tgz", "integrity": "sha512-rZ2ozlrRLIAGo2bjQ/ZUu4oZ/+ZjGvLkN5CKXSKBcu6xFO6k2bgqeM8a1836tAW+Pqp/ZFsTA5fZHsJZvP2D5g==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5556,7 +5544,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-6.5.0.tgz", "integrity": "sha512-cY0M3yoLC26mhAnjv1gyYNQjn7wxIXmL2hBmI/qs8g5uKuC2hRWi13ydufE3k4x0aNRjFGlg41fjoYLwaVF+9Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clean-coords": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5572,7 +5559,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-6.5.0.tgz", "integrity": "sha512-nIxkizjRdjKCYFQMnml6cjPsDOBCThrt+nkqtSEcxkKMhAQj5OO7o2CecioNTaX8EayqwMGVKcsz27oP4mKPTw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-disjoint": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5587,7 +5573,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-overlap/-/boolean-overlap-6.5.0.tgz", "integrity": "sha512-8btMIdnbXVWUa1M7D4shyaSGxLRw6NjMcqKBcsTXcZdnaixl22k7ar7BvIzkaRYN3SFECk9VGXfLncNS3ckQUw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -5605,7 +5590,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-parallel/-/boolean-parallel-6.5.0.tgz", "integrity": "sha512-aSHJsr1nq9e5TthZGZ9CZYeXklJyRgR5kCLm5X4urz7+MotMOp/LsGOsvKvK9NeUl9+8OUmfMn8EFTT8LkcvIQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clean-coords": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5621,7 +5605,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5635,7 +5618,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz", "integrity": "sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5649,7 +5631,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-within/-/boolean-within-6.5.0.tgz", "integrity": "sha512-YQB3oU18Inx35C/LU930D36RAVe7LDXk1kWsQ8mLmuqYn9YdPsDQTMTkLJMhoQ8EbN7QTdy333xRQ4MYgToteQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -5666,7 +5647,6 @@ "resolved": "https://registry.npmjs.org/@turf/buffer/-/buffer-6.5.0.tgz", "integrity": "sha512-qeX4N6+PPWbKqp1AVkBVWFerGjMYMUyencwfnkCesoznU6qvfugFHNAngNqIBVnJjZ5n8IFyOf+akcxnrt9sNg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/center": "^6.5.0", @@ -5685,7 +5665,6 @@ "resolved": "https://registry.npmjs.org/@turf/center/-/center-6.5.0.tgz", "integrity": "sha512-T8KtMTfSATWcAX088rEDKjyvQCBkUsLnK/Txb6/8WUXIeOZyHu42G7MkdkHRoHtwieLdduDdmPLFyTdG5/e7ZQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/helpers": "^6.5.0" @@ -5699,7 +5678,6 @@ "resolved": "https://registry.npmjs.org/@turf/center-mean/-/center-mean-6.5.0.tgz", "integrity": "sha512-AAX6f4bVn12pTVrMUiB9KrnV94BgeBKpyg3YpfnEbBpkN/znfVhL8dG8IxMAxAoSZ61Zt9WLY34HfENveuOZ7Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5714,7 +5692,6 @@ "resolved": "https://registry.npmjs.org/@turf/center-median/-/center-median-6.5.0.tgz", "integrity": "sha512-dT8Ndu5CiZkPrj15PBvslpuf01ky41DEYEPxS01LOxp5HOUHXp1oJxsPxvc+i/wK4BwccPNzU1vzJ0S4emd1KQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/center-mean": "^6.5.0", "@turf/centroid": "^6.5.0", @@ -5731,7 +5708,6 @@ "resolved": "https://registry.npmjs.org/@turf/center-of-mass/-/center-of-mass-6.5.0.tgz", "integrity": "sha512-EWrriU6LraOfPN7m1jZi+1NLTKNkuIsGLZc2+Y8zbGruvUW+QV7K0nhf7iZWutlxHXTBqEXHbKue/o79IumAsQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/centroid": "^6.5.0", "@turf/convex": "^6.5.0", @@ -5748,7 +5724,6 @@ "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.5.0.tgz", "integrity": "sha512-MwE1oq5E3isewPprEClbfU5pXljIK/GUOMbn22UM3IFPDJX0KeoyLNwghszkdmFp/qMGL/M13MMWvU+GNLXP/A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -5762,7 +5737,6 @@ "resolved": "https://registry.npmjs.org/@turf/circle/-/circle-6.5.0.tgz", "integrity": "sha512-oU1+Kq9DgRnoSbWFHKnnUdTmtcRUMmHoV9DjTXu9vOLNV5OWtAAh1VZ+mzsioGGzoDNT/V5igbFOkMfBQc0B6A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/destination": "^6.5.0", "@turf/helpers": "^6.5.0" @@ -5776,7 +5750,6 @@ "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-6.5.0.tgz", "integrity": "sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5790,7 +5763,6 @@ "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-6.5.0.tgz", "integrity": "sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -5803,7 +5775,6 @@ "resolved": "https://registry.npmjs.org/@turf/clusters/-/clusters-6.5.0.tgz", "integrity": "sha512-Y6gfnTJzQ1hdLfCsyd5zApNbfLIxYEpmDibHUqR5z03Lpe02pa78JtgrgUNt1seeO/aJ4TG1NLN8V5gOrHk04g==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -5817,7 +5788,6 @@ "resolved": "https://registry.npmjs.org/@turf/clusters-dbscan/-/clusters-dbscan-6.5.0.tgz", "integrity": "sha512-SxZEE4kADU9DqLRiT53QZBBhu8EP9skviSyl+FGj08Y01xfICM/RR9ACUdM0aEQimhpu+ZpRVcUK+2jtiCGrYQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clone": "^6.5.0", "@turf/distance": "^6.5.0", @@ -5834,7 +5804,6 @@ "resolved": "https://registry.npmjs.org/@turf/clusters-kmeans/-/clusters-kmeans-6.5.0.tgz", "integrity": "sha512-DwacD5+YO8kwDPKaXwT9DV46tMBVNsbi1IzdajZu1JDSWoN7yc7N9Qt88oi+p30583O0UPVkAK+A10WAQv4mUw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clone": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5851,7 +5820,6 @@ "resolved": "https://registry.npmjs.org/@turf/collect/-/collect-6.5.0.tgz", "integrity": "sha512-4dN/T6LNnRg099m97BJeOcTA5fSI8cu87Ydgfibewd2KQwBexO69AnjEFqfPX3Wj+Zvisj1uAVIZbPmSSrZkjg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -5867,7 +5835,6 @@ "resolved": "https://registry.npmjs.org/@turf/combine/-/combine-6.5.0.tgz", "integrity": "sha512-Q8EIC4OtAcHiJB3C4R+FpB4LANiT90t17uOd851qkM2/o6m39bfN5Mv0PWqMZIHWrrosZqRqoY9dJnzz/rJxYQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -5881,7 +5848,6 @@ "resolved": "https://registry.npmjs.org/@turf/concave/-/concave-6.5.0.tgz", "integrity": "sha512-I/sUmUC8TC5h/E2vPwxVht+nRt+TnXIPRoztDFvS8/Y0+cBDple9inLSo9nnPXMXidrBlGXZ9vQx/BjZUJgsRQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clone": "^6.5.0", "@turf/distance": "^6.5.0", @@ -5901,7 +5867,6 @@ "resolved": "https://registry.npmjs.org/@turf/convex/-/convex-6.5.0.tgz", "integrity": "sha512-x7ZwC5z7PJB0SBwNh7JCeCNx7Iu+QSrH7fYgK0RhhNop13TqUlvHMirMLRgf2db1DqUetrAO2qHJeIuasquUWg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0", @@ -5916,7 +5881,6 @@ "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz", "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5930,7 +5894,6 @@ "resolved": "https://registry.npmjs.org/@turf/difference/-/difference-6.5.0.tgz", "integrity": "sha512-l8iR5uJqvI+5Fs6leNbhPY5t/a3vipUF/3AeVLpwPQcgmedNXyheYuy07PcMGH5Jdpi5gItOiTqwiU/bUH4b3A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -5945,7 +5908,6 @@ "resolved": "https://registry.npmjs.org/@turf/dissolve/-/dissolve-6.5.0.tgz", "integrity": "sha512-WBVbpm9zLTp0Bl9CE35NomTaOL1c4TQCtEoO43YaAhNEWJOOIhZMFJyr8mbvYruKl817KinT3x7aYjjCMjTAsQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -5961,7 +5923,6 @@ "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz", "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -5975,7 +5936,6 @@ "resolved": "https://registry.npmjs.org/@turf/distance-weight/-/distance-weight-6.5.0.tgz", "integrity": "sha512-a8qBKkgVNvPKBfZfEJZnC3DV7dfIsC3UIdpRci/iap/wZLH41EmS90nM+BokAJflUHYy8PqE44wySGWHN1FXrQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/centroid": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -5991,7 +5951,6 @@ "resolved": "https://registry.npmjs.org/@turf/ellipse/-/ellipse-6.5.0.tgz", "integrity": "sha512-kuXtwFviw/JqnyJXF1mrR/cb496zDTSbGKtSiolWMNImYzGGkbsAsFTjwJYgD7+4FixHjp0uQPzo70KDf3AIBw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -6007,7 +5966,6 @@ "resolved": "https://registry.npmjs.org/@turf/envelope/-/envelope-6.5.0.tgz", "integrity": "sha512-9Z+FnBWvOGOU4X+fMZxYFs1HjFlkKqsddLuMknRaqcJd6t+NIv5DWvPtDL8ATD2GEExYDiFLwMdckfr1yqJgHA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/bbox-polygon": "^6.5.0", @@ -6022,7 +5980,6 @@ "resolved": "https://registry.npmjs.org/@turf/explode/-/explode-6.5.0.tgz", "integrity": "sha512-6cSvMrnHm2qAsace6pw9cDmK2buAlw8+tjeJVXMfMyY+w7ZUi1rprWMsY92J7s2Dar63Bv09n56/1V7+tcj52Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -6036,7 +5993,6 @@ "resolved": "https://registry.npmjs.org/@turf/flatten/-/flatten-6.5.0.tgz", "integrity": "sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -6050,7 +6006,6 @@ "resolved": "https://registry.npmjs.org/@turf/flip/-/flip-6.5.0.tgz", "integrity": "sha512-oyikJFNjt2LmIXQqgOGLvt70RgE2lyzPMloYWM7OR5oIFGRiBvqVD2hA6MNw6JewIm30fWZ8DQJw1NHXJTJPbg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clone": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6065,7 +6020,6 @@ "resolved": "https://registry.npmjs.org/@turf/great-circle/-/great-circle-6.5.0.tgz", "integrity": "sha512-7ovyi3HaKOXdFyN7yy1yOMa8IyOvV46RC1QOQTT+RYUN8ke10eyqExwBpL9RFUPvlpoTzoYbM/+lWPogQlFncg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -6079,7 +6033,6 @@ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", "license": "MIT", - "peer": true, "funding": { "url": "https://opencollective.com/turf" } @@ -6089,7 +6042,6 @@ "resolved": "https://registry.npmjs.org/@turf/hex-grid/-/hex-grid-6.5.0.tgz", "integrity": "sha512-Ln3tc2tgZT8etDOldgc6e741Smg1CsMKAz1/Mlel+MEL5Ynv2mhx3m0q4J9IB1F3a4MNjDeVvm8drAaf9SF33g==", "license": "MIT", - "peer": true, "dependencies": { "@turf/distance": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6105,7 +6057,6 @@ "resolved": "https://registry.npmjs.org/@turf/interpolate/-/interpolate-6.5.0.tgz", "integrity": "sha512-LSH5fMeiGyuDZ4WrDJNgh81d2DnNDUVJtuFryJFup8PV8jbs46lQGfI3r1DJ2p1IlEJIz3pmAZYeTfMMoeeohw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/centroid": "^6.5.0", @@ -6128,7 +6079,6 @@ "resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-6.5.0.tgz", "integrity": "sha512-2legGJeKrfFkzntcd4GouPugoqPUjexPZnOvfez+3SfIMrHvulw8qV8u7pfVyn2Yqs53yoVCEjS5sEpvQ5YRQg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -6143,7 +6093,6 @@ "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -6156,7 +6105,6 @@ "resolved": "https://registry.npmjs.org/@turf/isobands/-/isobands-6.5.0.tgz", "integrity": "sha512-4h6sjBPhRwMVuFaVBv70YB7eGz+iw0bhPRnp+8JBdX1UPJSXhoi/ZF2rACemRUr0HkdVB/a1r9gC32vn5IAEkw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/area": "^6.5.0", "@turf/bbox": "^6.5.0", @@ -6176,7 +6124,6 @@ "resolved": "https://registry.npmjs.org/@turf/isolines/-/isolines-6.5.0.tgz", "integrity": "sha512-6ElhiLCopxWlv4tPoxiCzASWt/jMRvmp6mRYrpzOm3EUl75OhHKa/Pu6Y9nWtCMmVC/RcWtiiweUocbPLZLm0A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6193,7 +6140,6 @@ "resolved": "https://registry.npmjs.org/@turf/kinks/-/kinks-6.5.0.tgz", "integrity": "sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -6206,7 +6152,6 @@ "resolved": "https://registry.npmjs.org/@turf/length/-/length-6.5.0.tgz", "integrity": "sha512-5pL5/pnw52fck3oRsHDcSGrj9HibvtlrZ0QNy2OcW8qBFDNgZ4jtl6U7eATVoyWPKBHszW3dWETW+iLV7UARig==", "license": "MIT", - "peer": true, "dependencies": { "@turf/distance": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6221,7 +6166,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-arc/-/line-arc-6.5.0.tgz", "integrity": "sha512-I6c+V6mIyEwbtg9P9zSFF89T7QPe1DPTG3MJJ6Cm1MrAY0MdejwQKOpsvNl8LDU2ekHOlz2kHpPVR7VJsoMllA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/circle": "^6.5.0", "@turf/destination": "^6.5.0", @@ -6236,7 +6180,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-chunk/-/line-chunk-6.5.0.tgz", "integrity": "sha512-i1FGE6YJaaYa+IJesTfyRRQZP31QouS+wh/pa6O3CC0q4T7LtHigyBSYjrbjSLfn2EVPYGlPCMFEqNWCOkC6zg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/length": "^6.5.0", @@ -6252,7 +6195,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz", "integrity": "sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -6269,7 +6211,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-offset/-/line-offset-6.5.0.tgz", "integrity": "sha512-CEXZbKgyz8r72qRvPchK0dxqsq8IQBdH275FE6o4MrBkzMcoZsfSjghtXzKaz9vvro+HfIXal0sTk2mqV1lQTw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -6284,7 +6225,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-overlap/-/line-overlap-6.5.0.tgz", "integrity": "sha512-xHOaWLd0hkaC/1OLcStCpfq55lPHpPNadZySDXYiYjEz5HXr1oKmtMYpn0wGizsLwrOixRdEp+j7bL8dPt4ojQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-point-on-line": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6304,7 +6244,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-6.5.0.tgz", "integrity": "sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -6319,7 +6258,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-slice/-/line-slice-6.5.0.tgz", "integrity": "sha512-vDqJxve9tBHhOaVVFXqVjF5qDzGtKWviyjbyi2QnSnxyFAmLlLnBfMX8TLQCAf2GxHibB95RO5FBE6I2KVPRuw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -6334,7 +6272,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-slice-along/-/line-slice-along-6.5.0.tgz", "integrity": "sha512-KHJRU6KpHrAj+BTgTNqby6VCTnDzG6a1sJx/I3hNvqMBLvWVA2IrkR9L9DtsQsVY63IBwVdQDqiwCuZLDQh4Ng==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bearing": "^6.5.0", "@turf/destination": "^6.5.0", @@ -6350,7 +6287,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-split/-/line-split-6.5.0.tgz", "integrity": "sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6372,7 +6308,6 @@ "resolved": "https://registry.npmjs.org/@turf/line-to-polygon/-/line-to-polygon-6.5.0.tgz", "integrity": "sha512-qYBuRCJJL8Gx27OwCD1TMijM/9XjRgXH/m/TyuND4OXedBpIWlK5VbTIO2gJ8OCfznBBddpjiObLBrkuxTpN4Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/clone": "^6.5.0", @@ -6388,7 +6323,6 @@ "resolved": "https://registry.npmjs.org/@turf/mask/-/mask-6.5.0.tgz", "integrity": "sha512-RQha4aU8LpBrmrkH8CPaaoAfk0Egj5OuXtv6HuCQnHeGNOQt3TQVibTA3Sh4iduq4EPxnZfDjgsOeKtrCA19lg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "polygon-clipping": "^0.15.3" @@ -6402,7 +6336,6 @@ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -6415,7 +6348,6 @@ "resolved": "https://registry.npmjs.org/@turf/midpoint/-/midpoint-6.5.0.tgz", "integrity": "sha512-MyTzV44IwmVI6ec9fB2OgZ53JGNlgOpaYl9ArKoF49rXpL84F9rNATndbe0+MQIhdkw8IlzA6xVP4lZzfMNVCw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bearing": "^6.5.0", "@turf/destination": "^6.5.0", @@ -6431,7 +6363,6 @@ "resolved": "https://registry.npmjs.org/@turf/moran-index/-/moran-index-6.5.0.tgz", "integrity": "sha512-ItsnhrU2XYtTtTudrM8so4afBCYWNaB0Mfy28NZwLjB5jWuAsvyV+YW+J88+neK/ougKMTawkmjQqodNJaBeLQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/distance-weight": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6446,7 +6377,6 @@ "resolved": "https://registry.npmjs.org/@turf/nearest-point/-/nearest-point-6.5.0.tgz", "integrity": "sha512-fguV09QxilZv/p94s8SMsXILIAMiaXI5PATq9d7YWijLxWUj6Q/r43kxyoi78Zmwwh1Zfqz9w+bCYUAxZ5+euA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clone": "^6.5.0", "@turf/distance": "^6.5.0", @@ -6462,7 +6392,6 @@ "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz", "integrity": "sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bearing": "^6.5.0", "@turf/destination": "^6.5.0", @@ -6481,7 +6410,6 @@ "resolved": "https://registry.npmjs.org/@turf/nearest-point-to-line/-/nearest-point-to-line-6.5.0.tgz", "integrity": "sha512-PXV7cN0BVzUZdjj6oeb/ESnzXSfWmEMrsfZSDRgqyZ9ytdiIj/eRsnOXLR13LkTdXVOJYDBuf7xt1mLhM4p6+Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -6498,7 +6426,6 @@ "resolved": "https://registry.npmjs.org/@turf/planepoint/-/planepoint-6.5.0.tgz", "integrity": "sha512-R3AahA6DUvtFbka1kcJHqZ7DMHmPXDEQpbU5WaglNn7NaCQg9HB0XM0ZfqWcd5u92YXV+Gg8QhC8x5XojfcM4Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -6512,7 +6439,6 @@ "resolved": "https://registry.npmjs.org/@turf/point-grid/-/point-grid-6.5.0.tgz", "integrity": "sha512-Iq38lFokNNtQJnOj/RBKmyt6dlof0yhaHEDELaWHuECm1lIZLY3ZbVMwbs+nXkwTAHjKfS/OtMheUBkw+ee49w==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-within": "^6.5.0", "@turf/distance": "^6.5.0", @@ -6528,7 +6454,6 @@ "resolved": "https://registry.npmjs.org/@turf/point-on-feature/-/point-on-feature-6.5.0.tgz", "integrity": "sha512-bDpuIlvugJhfcF/0awAQ+QI6Om1Y1FFYE8Y/YdxGRongivix850dTeXCo0mDylFdWFPGDo7Mmh9Vo4VxNwW/TA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/center": "^6.5.0", @@ -6545,7 +6470,6 @@ "resolved": "https://registry.npmjs.org/@turf/point-to-line-distance/-/point-to-line-distance-6.5.0.tgz", "integrity": "sha512-opHVQ4vjUhNBly1bob6RWy+F+hsZDH9SA0UW36pIRzfpu27qipU18xup0XXEePfY6+wvhF6yL/WgCO2IbrLqEA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bearing": "^6.5.0", "@turf/distance": "^6.5.0", @@ -6565,7 +6489,6 @@ "resolved": "https://registry.npmjs.org/@turf/points-within-polygon/-/points-within-polygon-6.5.0.tgz", "integrity": "sha512-YyuheKqjliDsBDt3Ho73QVZk1VXX1+zIA2gwWvuz8bR1HXOkcuwk/1J76HuFMOQI3WK78wyAi+xbkx268PkQzQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6580,7 +6503,6 @@ "resolved": "https://registry.npmjs.org/@turf/polygon-smooth/-/polygon-smooth-6.5.0.tgz", "integrity": "sha512-LO/X/5hfh/Rk4EfkDBpLlVwt3i6IXdtQccDT9rMjXEP32tRgy0VMFmdkNaXoGlSSKf/1mGqLl4y4wHd86DqKbg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -6594,7 +6516,6 @@ "resolved": "https://registry.npmjs.org/@turf/polygon-tangents/-/polygon-tangents-6.5.0.tgz", "integrity": "sha512-sB4/IUqJMYRQH9jVBwqS/XDitkEfbyqRy+EH/cMRJURTg78eHunvJ708x5r6umXsbiUyQU4eqgPzEylWEQiunw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/boolean-within": "^6.5.0", @@ -6612,7 +6533,6 @@ "resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-6.5.0.tgz", "integrity": "sha512-5p4n/ij97EIttAq+ewSnKt0ruvuM+LIDzuczSzuHTpq4oS7Oq8yqg5TQ4nzMVuK41r/tALCk7nAoBuw3Su4Gcw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -6626,7 +6546,6 @@ "resolved": "https://registry.npmjs.org/@turf/polygonize/-/polygonize-6.5.0.tgz", "integrity": "sha512-a/3GzHRaCyzg7tVYHo43QUChCspa99oK4yPqooVIwTC61npFzdrmnywMv0S+WZjHZwK37BrFJGFrZGf6ocmY5w==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/envelope": "^6.5.0", @@ -6643,7 +6562,6 @@ "resolved": "https://registry.npmjs.org/@turf/projection/-/projection-6.5.0.tgz", "integrity": "sha512-/Pgh9mDvQWWu8HRxqpM+tKz8OzgauV+DiOcr3FCjD6ubDnrrmMJlsf6fFJmggw93mtVPrZRL6yyi9aYCQBOIvg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clone": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6658,7 +6576,6 @@ "resolved": "https://registry.npmjs.org/@turf/random/-/random-6.5.0.tgz", "integrity": "sha512-8Q25gQ/XbA7HJAe+eXp4UhcXM9aOOJFaxZ02+XSNwMvY8gtWSCBLVqRcW4OhqilgZ8PeuQDWgBxeo+BIqqFWFQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -6671,7 +6588,6 @@ "resolved": "https://registry.npmjs.org/@turf/rectangle-grid/-/rectangle-grid-6.5.0.tgz", "integrity": "sha512-yQZ/1vbW68O2KsSB3OZYK+72aWz/Adnf7m2CMKcC+aq6TwjxZjAvlbCOsNUnMAuldRUVN1ph6RXMG4e9KEvKvg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-intersects": "^6.5.0", "@turf/distance": "^6.5.0", @@ -6686,7 +6602,6 @@ "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-6.5.0.tgz", "integrity": "sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-clockwise": "^6.5.0", "@turf/clone": "^6.5.0", @@ -6703,7 +6618,6 @@ "resolved": "https://registry.npmjs.org/@turf/rhumb-bearing/-/rhumb-bearing-6.5.0.tgz", "integrity": "sha512-jMyqiMRK4hzREjQmnLXmkJ+VTNTx1ii8vuqRwJPcTlKbNWfjDz/5JqJlb5NaFDcdMpftWovkW5GevfnuzHnOYA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -6717,7 +6631,6 @@ "resolved": "https://registry.npmjs.org/@turf/rhumb-destination/-/rhumb-destination-6.5.0.tgz", "integrity": "sha512-RHNP1Oy+7xTTdRrTt375jOZeHceFbjwohPHlr9Hf68VdHHPMAWgAKqiX2YgSWDcvECVmiGaBKWus1Df+N7eE4Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -6731,7 +6644,6 @@ "resolved": "https://registry.npmjs.org/@turf/rhumb-distance/-/rhumb-distance-6.5.0.tgz", "integrity": "sha512-oKp8KFE8E4huC2Z1a1KNcFwjVOqa99isxNOwfo4g3SUABQ6NezjKDDrnvC4yI5YZ3/huDjULLBvhed45xdCrzg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" @@ -6745,7 +6657,6 @@ "resolved": "https://registry.npmjs.org/@turf/sample/-/sample-6.5.0.tgz", "integrity": "sha512-kSdCwY7el15xQjnXYW520heKUrHwRvnzx8ka4eYxX9NFeOxaFITLW2G7UtXb6LJK8mmPXI8Aexv23F2ERqzGFg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -6758,7 +6669,6 @@ "resolved": "https://registry.npmjs.org/@turf/sector/-/sector-6.5.0.tgz", "integrity": "sha512-cYUOkgCTWqa23SOJBqxoFAc/yGCUsPRdn/ovbRTn1zNTm/Spmk6hVB84LCKOgHqvSF25i0d2kWqpZDzLDdAPbw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/circle": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6775,7 +6685,6 @@ "resolved": "https://registry.npmjs.org/@turf/shortest-path/-/shortest-path-6.5.0.tgz", "integrity": "sha512-4de5+G7+P4hgSoPwn+SO9QSi9HY5NEV/xRJ+cmoFVRwv2CDsuOPDheHKeuIAhKyeKDvPvPt04XYWbac4insJMg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/bbox-polygon": "^6.5.0", @@ -6796,7 +6705,6 @@ "resolved": "https://registry.npmjs.org/@turf/simplify/-/simplify-6.5.0.tgz", "integrity": "sha512-USas3QqffPHUY184dwQdP8qsvcVH/PWBYdXY5am7YTBACaQOMAlf6AKJs9FT8jiO6fQpxfgxuEtwmox+pBtlOg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clean-coords": "^6.5.0", "@turf/clone": "^6.5.0", @@ -6812,7 +6720,6 @@ "resolved": "https://registry.npmjs.org/@turf/square/-/square-6.5.0.tgz", "integrity": "sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/distance": "^6.5.0", "@turf/helpers": "^6.5.0" @@ -6826,7 +6733,6 @@ "resolved": "https://registry.npmjs.org/@turf/square-grid/-/square-grid-6.5.0.tgz", "integrity": "sha512-mlR0ayUdA+L4c9h7p4k3pX6gPWHNGuZkt2c5II1TJRmhLkW2557d6b/Vjfd1z9OVaajb1HinIs1FMSAPXuuUrA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/rectangle-grid": "^6.5.0" @@ -6840,7 +6746,6 @@ "resolved": "https://registry.npmjs.org/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-6.5.0.tgz", "integrity": "sha512-02CAlz8POvGPFK2BKK8uHGUk/LXb0MK459JVjKxLC2yJYieOBTqEbjP0qaWhiBhGzIxSMaqe8WxZ0KvqdnstHA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/center-mean": "^6.5.0", "@turf/ellipse": "^6.5.0", @@ -6858,7 +6763,6 @@ "resolved": "https://registry.npmjs.org/@turf/tag/-/tag-6.5.0.tgz", "integrity": "sha512-XwlBvrOV38CQsrNfrxvBaAPBQgXMljeU0DV8ExOyGM7/hvuGHJw3y8kKnQ4lmEQcmcrycjDQhP7JqoRv8vFssg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/clone": "^6.5.0", @@ -6874,7 +6778,6 @@ "resolved": "https://registry.npmjs.org/@turf/tesselate/-/tesselate-6.5.0.tgz", "integrity": "sha512-M1HXuyZFCfEIIKkglh/r5L9H3c5QTEsnMBoZOFQiRnGPGmJWcaBissGb7mTFX2+DKE7FNWXh4TDnZlaLABB0dQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "earcut": "^2.0.0" @@ -6888,7 +6791,6 @@ "resolved": "https://registry.npmjs.org/@turf/tin/-/tin-6.5.0.tgz", "integrity": "sha512-YLYikRzKisfwj7+F+Tmyy/LE3d2H7D4kajajIfc9mlik2+esG7IolsX/+oUz1biguDYsG0DUA8kVYXDkobukfg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0" }, @@ -6901,7 +6803,6 @@ "resolved": "https://registry.npmjs.org/@turf/transform-rotate/-/transform-rotate-6.5.0.tgz", "integrity": "sha512-A2Ip1v4246ZmpssxpcL0hhiVBEf4L8lGnSPWTgSv5bWBEoya2fa/0SnFX9xJgP40rMP+ZzRaCN37vLHbv1Guag==", "license": "MIT", - "peer": true, "dependencies": { "@turf/centroid": "^6.5.0", "@turf/clone": "^6.5.0", @@ -6921,7 +6822,6 @@ "resolved": "https://registry.npmjs.org/@turf/transform-scale/-/transform-scale-6.5.0.tgz", "integrity": "sha512-VsATGXC9rYM8qTjbQJ/P7BswKWXHdnSJ35JlV4OsZyHBMxJQHftvmZJsFbOqVtQnIQIzf2OAly6rfzVV9QLr7g==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "^6.5.0", "@turf/center": "^6.5.0", @@ -6943,7 +6843,6 @@ "resolved": "https://registry.npmjs.org/@turf/transform-translate/-/transform-translate-6.5.0.tgz", "integrity": "sha512-NABLw5VdtJt/9vSstChp93pc6oel4qXEos56RBMsPlYB8hzNTEKYtC146XJvyF4twJeeYS8RVe1u7KhoFwEM5w==", "license": "MIT", - "peer": true, "dependencies": { "@turf/clone": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6960,7 +6859,6 @@ "resolved": "https://registry.npmjs.org/@turf/triangle-grid/-/triangle-grid-6.5.0.tgz", "integrity": "sha512-2jToUSAS1R1htq4TyLQYPTIsoy6wg3e3BQXjm2rANzw4wPQCXGOxrur1Fy9RtzwqwljlC7DF4tg0OnWr8RjmfA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/distance": "^6.5.0", "@turf/helpers": "^6.5.0", @@ -6975,7 +6873,6 @@ "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-6.5.0.tgz", "integrity": "sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" @@ -6989,7 +6886,6 @@ "resolved": "https://registry.npmjs.org/@turf/turf/-/turf-6.5.0.tgz", "integrity": "sha512-ipMCPnhu59bh92MNt8+pr1VZQhHVuTMHklciQURo54heoxRzt1neNYZOBR6jdL+hNsbDGAECMuIpAutX+a3Y+w==", "license": "MIT", - "peer": true, "dependencies": { "@turf/along": "^6.5.0", "@turf/angle": "^6.5.0", @@ -7106,7 +7002,6 @@ "resolved": "https://registry.npmjs.org/@turf/union/-/union-6.5.0.tgz", "integrity": "sha512-igYWCwP/f0RFHIlC2c0SKDuM/ObBaqSljI3IdV/x71805QbIvY/BYGcJdyNcgEA6cylIGl/0VSlIbpJHZ9ldhw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -7121,7 +7016,6 @@ "resolved": "https://registry.npmjs.org/@turf/unkink-polygon/-/unkink-polygon-6.5.0.tgz", "integrity": "sha512-8QswkzC0UqKmN1DT6HpA9upfa1HdAA5n6bbuzHy8NJOX8oVizVAqfEPY0wqqTgboDjmBR4yyImsdPGUl3gZ8JQ==", "license": "MIT", - "peer": true, "dependencies": { "@turf/area": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -7138,7 +7032,6 @@ "resolved": "https://registry.npmjs.org/@turf/voronoi/-/voronoi-6.5.0.tgz", "integrity": "sha512-C/xUsywYX+7h1UyNqnydHXiun4UPjK88VDghtoRypR9cLlb7qozkiLRphQxxsCM0KxyxpVPHBVQXdAL3+Yurow==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", @@ -7259,8 +7152,7 @@ "version": "7946.0.8", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/http-errors": { "version": "2.0.5", @@ -8310,8 +8202,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/atomic-sleep": { "version": "1.0.0", @@ -8327,7 +8218,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -8339,7 +8229,6 @@ "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "is-retry-allowed": "^2.2.0" }, @@ -8478,7 +8367,6 @@ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -8664,7 +8552,6 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", - "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -8759,7 +8646,6 @@ "resolved": "https://registry.npmjs.org/concaveman/-/concaveman-2.0.0.tgz", "integrity": "sha512-3a9C//4G44/boNehBPZMRh8XxrwBvTXlhENUim+GMm207WoDie/Vq89U5lkhLn3kKA+vxwmwfdQPWIRwjQWoLA==", "license": "ISC", - "peer": true, "dependencies": { "point-in-polygon": "^1.1.0", "rbush": "^4.0.1", @@ -8771,15 +8657,13 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/concaveman/node_modules/rbush": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", "license": "MIT", - "peer": true, "dependencies": { "quickselect": "^3.0.0" } @@ -9023,15 +8907,13 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/d3-geo": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "1" } @@ -9040,8 +8922,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.2.tgz", "integrity": "sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dargs": { "version": "8.1.0", @@ -9107,7 +8988,6 @@ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "license": "MIT", - "peer": true, "dependencies": { "is-arguments": "^1.1.1", "is-date-object": "^1.0.5", @@ -9144,7 +9024,6 @@ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", - "peer": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -9162,7 +9041,6 @@ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", - "peer": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -9180,7 +9058,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.4.0" } @@ -9189,8 +9066,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/density-clustering/-/density-clustering-1.3.0.tgz", "integrity": "sha512-icpmBubVTwLnsaor9qH/4tG5+7+f61VcqMN3V3pm9sxxSCt2Jcs0zWOgwZW9ARJYaKD3FumIgHiMOcIMRRAzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/depd": { "version": "2.0.0", @@ -9255,8 +9131,7 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -9366,7 +9241,6 @@ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -10233,7 +10107,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=4.0" }, @@ -10265,7 +10138,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -10336,7 +10208,6 @@ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -10385,7 +10256,6 @@ "resolved": "https://registry.npmjs.org/geojson-equality/-/geojson-equality-0.1.6.tgz", "integrity": "sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ==", "license": "MIT", - "peer": true, "dependencies": { "deep-equal": "^1.0.0" } @@ -10395,7 +10265,6 @@ "resolved": "https://registry.npmjs.org/geojson-rbush/-/geojson-rbush-3.2.0.tgz", "integrity": "sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w==", "license": "MIT", - "peer": true, "dependencies": { "@turf/bbox": "*", "@turf/helpers": "6.x", @@ -10408,15 +10277,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/geojson-rbush/node_modules/rbush": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", "license": "MIT", - "peer": true, "dependencies": { "quickselect": "^2.0.0" } @@ -10612,7 +10479,6 @@ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", - "peer": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -10637,7 +10503,6 @@ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", - "peer": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -10847,7 +10712,6 @@ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -10909,7 +10773,6 @@ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -10975,7 +10838,6 @@ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", @@ -10994,7 +10856,6 @@ "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -11680,7 +11541,6 @@ "resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz", "integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==", "license": "MIT", - "peer": true, "engines": { "node": ">=v0.2.0" } @@ -11730,7 +11590,6 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11752,7 +11611,6 @@ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "license": "MIT", - "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" @@ -11769,7 +11627,6 @@ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -12547,15 +12404,13 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/polygon-clipping": { "version": "0.15.7", "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==", "license": "MIT", - "peer": true, "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" @@ -12790,7 +12645,6 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -12870,8 +12724,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/range-parser": { "version": "1.2.1", @@ -12902,7 +12755,6 @@ "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", "license": "MIT", - "peer": true, "dependencies": { "quickselect": "^1.0.1" } @@ -12952,7 +12804,6 @@ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "license": "MIT", - "peer": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -13108,8 +12959,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense", - "peer": true + "license": "Unlicense" }, "node_modules/rollup": { "version": "4.53.3", @@ -13307,7 +13157,6 @@ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", - "peer": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -13325,7 +13174,6 @@ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "license": "MIT", - "peer": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -13482,8 +13330,7 @@ "version": "0.9.7", "resolved": "https://registry.npmjs.org/skmeans/-/skmeans-0.9.7.tgz", "integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/slash": { "version": "3.0.0", @@ -13538,7 +13385,6 @@ "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz", "integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.20 || >=20" } @@ -13998,8 +13844,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/tinyrainbow": { "version": "2.0.0", @@ -14047,7 +13892,6 @@ "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", "license": "ISC", - "peer": true, "dependencies": { "commander": "2" }, @@ -14061,15 +13905,13 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/topojson-server": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/topojson-server/-/topojson-server-3.0.1.tgz", "integrity": "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==", "license": "ISC", - "peer": true, "dependencies": { "commander": "2" }, @@ -14081,8 +13923,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/totalist": { "version": "3.0.1", @@ -14173,8 +14014,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/turf-jsts/-/turf-jsts-1.2.3.tgz", "integrity": "sha512-Ja03QIJlPuHt4IQ2FfGex4F4JAr8m3jpaHbFbQrgwr7s7L6U8ocrHiF3J1+wf9jzhGKxvDeaCAnGDot8OjGFyA==", - "license": "(EDL-1.0 OR EPL-1.0)", - "peer": true + "license": "(EDL-1.0 OR EPL-1.0)" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 3daf502..d45e0c2 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@map-colonies/jobnik-sdk": "^0.1.0", "@map-colonies/js-logger": "^5.0.0", "@map-colonies/mc-priority-queue": "^9.1.0", + "@map-colonies/mc-utils": "^5.1.0", "@map-colonies/raster-shared": "^8.1.0-alpha.3", "@map-colonies/read-pkg": "^1.0.0", "@map-colonies/schemas": "^1.20.0", diff --git a/src/cleaner/httpClients/index.ts b/src/cleaner/httpClients/index.ts new file mode 100644 index 0000000..f6e941b --- /dev/null +++ b/src/cleaner/httpClients/index.ts @@ -0,0 +1 @@ +export { JobTrackerClient } from './jobTrackerClient'; diff --git a/src/cleaner/httpClients/jobTrackerClient.ts b/src/cleaner/httpClients/jobTrackerClient.ts new file mode 100644 index 0000000..373e991 --- /dev/null +++ b/src/cleaner/httpClients/jobTrackerClient.ts @@ -0,0 +1,29 @@ +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { HttpClient, type IHttpRetryConfig } from '@map-colonies/mc-utils'; +import { SERVICES } from '@common/constants'; +import type { ConfigType } from '@common/config'; + +/** + * Notifies job-tracker that a task reached a terminal state (Completed or Failed). + */ +@injectable() +export class JobTrackerClient extends HttpClient { + public constructor( + @inject(SERVICES.LOGGER) protected override readonly logger: Logger, + @inject(SERVICES.CONFIG) config: ConfigType + ) { + const baseUrl = config.get('servicesUrl.jobTracker') as unknown as string; + const httpRetryConfig = config.get('httpRetry') as IHttpRetryConfig; + const disableHttpClientLogs = config.get('disableHttpClientLogs') as boolean; + super(logger, baseUrl, 'JobTracker', httpRetryConfig, disableHttpClientLogs); + } + + public async notify(taskId: string): Promise { + try { + await this.post(`tasks/${taskId}/notify`); + } catch (error) { + this.logger.error({ msg: 'Failed to notify job tracker', taskId, error }); + } + } +} diff --git a/src/common/constants.ts b/src/common/constants.ts index 2bfe7ef..01c8bfd 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -20,6 +20,7 @@ export const SERVICES = { POLLING_PAIRS: Symbol('PollingPairs'), STORAGE_PROVIDERS: Symbol('StorageProviders'), TASK_CONTEXT: Symbol('TaskContext'), + JOB_TRACKER_CLIENT: Symbol('JobTrackerClient'), // ============================================================================= // TODO: When we move to the new job-manager, we will use @map-colonies/jobnik-sdk // The tokens below are kept for future migration. diff --git a/src/containerConfig.ts b/src/containerConfig.ts index be6fb83..586613b 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -16,6 +16,7 @@ import { workerBuilder } from './worker'; import { StrategyFactory, TilesDeletionStrategy } from './cleaner/strategies'; import { ErrorHandler } from './cleaner/errors'; import { S3StorageProvider, FsStorageProvider, type IStorageProvider } from './cleaner/storageProviders'; +import { JobTrackerClient } from './cleaner/httpClients'; export interface RegisterOptions { override?: InjectionObject[]; @@ -90,6 +91,12 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise useClass: ErrorHandler, }, }, + { + token: SERVICES.JOB_TRACKER_CLIENT, + provider: { + useClass: JobTrackerClient, + }, + }, { token: SERVICES.STORAGE_PROVIDERS, provider: { diff --git a/src/worker/taskPoller.ts b/src/worker/taskPoller.ts index 2bacac2..2c66f1d 100644 --- a/src/worker/taskPoller.ts +++ b/src/worker/taskPoller.ts @@ -8,6 +8,7 @@ import type { ConfigType } from '@common/config'; import type { PollingPairConfig } from '../cleaner/types'; import type { StrategyFactory } from '../cleaner/strategies'; import { UnrecoverableError, type ErrorHandler } from '../cleaner/errors'; +import type { JobTrackerClient } from '../cleaner/httpClients'; /** * TaskPoller - Simple bridge to implement IWorker using the old mc-priority-queue SDK @@ -23,7 +24,8 @@ export class TaskPoller implements IWorker { @inject(SERVICES.QUEUE_CLIENT) private readonly queueClient: QueueClient, @inject(SERVICES.STRATEGY_FACTORY) private readonly strategyFactory: StrategyFactory, @inject(SERVICES.ERROR_HANDLER) private readonly errorHandler: ErrorHandler, - @inject(SERVICES.POLLING_PAIRS) private readonly pollingPairs: PollingPairConfig[] + @inject(SERVICES.POLLING_PAIRS) private readonly pollingPairs: PollingPairConfig[], + @inject(SERVICES.JOB_TRACKER_CLIENT) private readonly jobTrackerClient: JobTrackerClient ) { this.dequeueIntervalMs = config.get('queue.dequeueIntervalMs') as unknown as number; //TODO:when we create worker config schema we can remove the cast } @@ -116,6 +118,7 @@ export class TaskPoller implements IWorker { await strategy.execute(validated); await this.queueClient.ack(task.jobId, task.id); + await this.jobTrackerClient.notify(task.id); const durationMs = Date.now() - startTime; this.logger.info({ msg: 'Task completed', taskId: task.id, durationMs }); @@ -135,8 +138,13 @@ export class TaskPoller implements IWorker { try { await this.queueClient.reject(task.jobId, task.id, decision.shouldRetry, decision.reason); - } catch (error) { - this.logger.error({ msg: 'Failed to reject task', taskId: task.id, error }); + } catch (rejectError) { + this.logger.error({ msg: 'Failed to reject task', taskId: task.id, error: rejectError }); + return; + } + + if (!decision.shouldRetry) { + await this.jobTrackerClient.notify(task.id); } } } diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index 2eeb1bc..079851f 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -6,6 +6,7 @@ import type { ITaskStrategy, StrategyFactory } from '../../src/cleaner/strategie import type { IStorageProvider } from '../../src/cleaner/storageProviders'; import type { ErrorHandler } from '../../src/cleaner/errors'; import type { ErrorDecision, PollingPairConfig } from '../../src/cleaner/types'; +import type { JobTrackerClient } from '../../src/cleaner/httpClients'; import { TaskPoller } from '../../src/worker/taskPoller'; // ─── Logger ────────────────────────────────────────────────────────────────── @@ -111,6 +112,12 @@ export function createMockS3Config(): ConfigType { } as unknown as ConfigType; } +// ─── JobTrackerClient ───────────────────────────────────────────────────────── + +export function createMockJobTrackerClient(): JobTrackerClient { + return { notify: vi.fn().mockResolvedValue(undefined) } as unknown as JobTrackerClient; +} + // ─── TaskPoller factory ─────────────────────────────────────────────────────── /** @@ -124,6 +131,7 @@ export function createTaskPoller({ strategyFactory = createMockStrategyFactory(), errorHandler = createMockErrorHandler(), pollingPairs, + jobTrackerClient = createMockJobTrackerClient(), }: { logger?: Logger; config?: ConfigType; @@ -131,6 +139,7 @@ export function createTaskPoller({ strategyFactory?: StrategyFactory; errorHandler?: ErrorHandler; pollingPairs: PollingPairConfig[]; + jobTrackerClient?: JobTrackerClient; }): TaskPoller { - return new TaskPoller(logger, config, queueClient, strategyFactory, errorHandler, pollingPairs); + return new TaskPoller(logger, config, queueClient, strategyFactory, errorHandler, pollingPairs, jobTrackerClient); } diff --git a/tests/httpClients/jobTrackerClient.spec.ts b/tests/httpClients/jobTrackerClient.spec.ts new file mode 100644 index 0000000..1a78964 --- /dev/null +++ b/tests/httpClients/jobTrackerClient.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Logger } from '@map-colonies/js-logger'; +import { faker } from '@faker-js/faker'; +import { JobTrackerClient } from '@src/cleaner/httpClients/jobTrackerClient'; +import type { ConfigType } from '@src/common/config'; +import { createMockLogger } from '../helpers/mocks'; + +const mockPost = vi.fn(); + +vi.mock('@map-colonies/mc-utils', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + HttpClient: class { + public post = mockPost; + }, +})); + +const JOB_TRACKER_URL = 'http://job-tracker.test'; + +function buildConfig(): ConfigType { + const values: Record = { + 'servicesUrl.jobTracker': JOB_TRACKER_URL, + httpRetry: { attempts: 3, delay: 'exponential', shouldResetTimeout: true }, + disableHttpClientLogs: false, + }; + return { get: vi.fn((key: string) => values[key]) } as unknown as ConfigType; +} + +describe('JobTrackerClient', () => { + let client: JobTrackerClient; + let logger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockPost.mockResolvedValue(undefined); + logger = createMockLogger(); + client = new JobTrackerClient(logger, buildConfig()); + }); + + it('POSTs to /tasks/{taskId}/notify', async () => { + await client.notify('abc-123'); + + expect(mockPost).toHaveBeenCalledWith('tasks/abc-123/notify'); + }); + + it('resolves without throwing on a failed notification (fire-and-forget)', async () => { + mockPost.mockRejectedValue(new Error('500 Internal Server Error')); + + await expect(client.notify('abc-123')).resolves.toBeUndefined(); + }); + + it('logs the error and does not rethrow when notification fails', async () => { + const failure = new Error('500 Internal Server Error'); + const taskId = faker.string.uuid(); + mockPost.mockRejectedValue(failure); + + await expect(client.notify(taskId)).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ msg: 'Failed to notify job tracker', taskId, error: failure })); + }); + + it('encodes each call with its own taskId', async () => { + const [firstId, secondId] = [faker.string.uuid(), faker.string.uuid()]; + await client.notify(firstId); + await client.notify(secondId); + + expect(mockPost).toHaveBeenNthCalledWith(1, `tasks/${firstId}/notify`); + expect(mockPost).toHaveBeenNthCalledWith(2, `tasks/${secondId}/notify`); + }); +}); diff --git a/tests/taskPoller.spec.ts b/tests/taskPoller.spec.ts index 6e7b3bf..fb97375 100644 --- a/tests/taskPoller.spec.ts +++ b/tests/taskPoller.spec.ts @@ -5,13 +5,22 @@ import { StrategyFactory } from '@src/cleaner/strategies'; import { TaskPoller } from '@src/worker/taskPoller'; import type { PollingPairConfig } from '@src/cleaner/types'; import { ErrorHandler, UnrecoverableError } from '@src/cleaner/errors'; -import { createMockQueueClient, createMockStrategyFactory, createMockErrorHandler, createTaskPoller, buildMockStrategy } from './helpers/mocks'; +import type { JobTrackerClient } from '@src/cleaner/httpClients'; +import { + createMockQueueClient, + createMockStrategyFactory, + createMockErrorHandler, + createMockJobTrackerClient, + createTaskPoller, + buildMockStrategy, +} from './helpers/mocks'; import { buildTask, buildPair } from './helpers/fakes'; describe('TaskPoller', () => { let queueClient: QueueClient; let strategyFactory: StrategyFactory; let errorHandler: ErrorHandler; + let jobTrackerClient: JobTrackerClient; let pollingPairs: PollingPairConfig[]; let poller: TaskPoller; @@ -33,8 +42,9 @@ describe('TaskPoller', () => { queueClient = createMockQueueClient(); strategyFactory = createMockStrategyFactory(); errorHandler = createMockErrorHandler(); + jobTrackerClient = createMockJobTrackerClient(); pollingPairs = [buildPair({ maxAttempts: 5 })]; - poller = createTaskPoller({ queueClient, strategyFactory, errorHandler, pollingPairs }); + poller = createTaskPoller({ queueClient, strategyFactory, errorHandler, pollingPairs, jobTrackerClient }); }); describe('stop()', () => { @@ -54,6 +64,7 @@ describe('TaskPoller', () => { await poller.start(); expect(queueClient.ack).toHaveBeenCalledWith(task.jobId, task.id); + expect(jobTrackerClient.notify).toHaveBeenCalledWith(task.id); }); }); @@ -66,6 +77,7 @@ describe('TaskPoller', () => { expect(queueClient.ack).not.toHaveBeenCalled(); expect(queueClient.reject).not.toHaveBeenCalled(); expect(strategyFactory.resolveWithContext).not.toHaveBeenCalled(); + expect(jobTrackerClient.notify).not.toHaveBeenCalled(); }); it('resolveWithContext → validate → execute → ack on the success path', async () => { @@ -91,11 +103,12 @@ describe('TaskPoller', () => { expect(strategy.execute).toHaveBeenCalledWith(validated); expect(queueClient.ack).toHaveBeenCalledWith(task.jobId, task.id); expect(queueClient.reject).not.toHaveBeenCalled(); + expect(jobTrackerClient.notify).toHaveBeenCalledWith(task.id); }); it('skips an erroring pair and continues to the next', async () => { pollingPairs = [buildPair({ jobType: 'Job_A', taskType: 'task-a' }), buildPair({ jobType: 'Job_B', taskType: 'task-b' })]; - poller = createTaskPoller({ queueClient, strategyFactory, errorHandler, pollingPairs }); + poller = createTaskPoller({ queueClient, strategyFactory, errorHandler, pollingPairs, jobTrackerClient }); const task = buildTask({ attempts: 1 }); vi.mocked(strategyFactory.resolveWithContext).mockReturnValue(buildMockStrategy()); @@ -105,24 +118,27 @@ describe('TaskPoller', () => { await poller.start(); expect(queueClient.ack).toHaveBeenCalledWith(task.jobId, task.id); + expect(jobTrackerClient.notify).toHaveBeenCalledWith(task.id); }); it('stops iterating pairs mid-loop when shouldStop becomes true', async () => { pollingPairs = [buildPair({ jobType: 'Job_A', taskType: 'task-a' }), buildPair({ jobType: 'Job_B', taskType: 'task-b' })]; - poller = createTaskPoller({ queueClient, strategyFactory, errorHandler, pollingPairs }); + poller = createTaskPoller({ queueClient, strategyFactory, errorHandler, pollingPairs, jobTrackerClient }); stopOnDequeue(); await poller.start(); expect(queueClient.dequeue).toHaveBeenCalledTimes(1); + expect(jobTrackerClient.notify).not.toHaveBeenCalled(); }); }); describe('handleTaskFailure()', () => { it('rejects without calling strategy when task.attempts reaches maxAttempts', async () => { const pair = pollingPairs[0]!; - vi.mocked(queueClient.dequeue).mockResolvedValue(buildTask({ attempts: pair.maxAttempts })); + const task = buildTask({ attempts: pair.maxAttempts }); + vi.mocked(queueClient.dequeue).mockResolvedValue(task); stopOnReject(); await poller.start(); @@ -132,6 +148,8 @@ describe('TaskPoller', () => { expect(errorHandler.handleError).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(UnrecoverableError) })); expect(queueClient.ack).not.toHaveBeenCalled(); expect(queueClient.reject).toHaveBeenCalled(); + // Default mock errorHandler returns shouldRetry=false → terminal failure → notify fires. + expect(jobTrackerClient.notify).toHaveBeenCalledWith(task.id); }); it('calls reject with the decision returned by errorHandler', async () => { @@ -149,18 +167,23 @@ describe('TaskPoller', () => { expect(errorHandler.handleError).toHaveBeenCalledWith(expect.objectContaining({ jobId: task.jobId, taskId: task.id, error: execError })); expect(queueClient.reject).toHaveBeenCalledWith(task.jobId, task.id, true, 'retry it'); expect(queueClient.ack).not.toHaveBeenCalled(); + // Retryable failure → no terminal notification. + expect(jobTrackerClient.notify).not.toHaveBeenCalled(); }); it('passes the raw thrown value to errorHandler without wrapping', async () => { + const task = buildTask({ attempts: 1 }); const strategy = buildMockStrategy(); vi.mocked(strategy.execute).mockRejectedValue('raw string'); vi.mocked(strategyFactory.resolveWithContext).mockReturnValue(strategy); - vi.mocked(queueClient.dequeue).mockResolvedValue(buildTask({ attempts: 1 })); + vi.mocked(queueClient.dequeue).mockResolvedValue(task); stopOnReject(); await poller.start(); expect(errorHandler.handleError).toHaveBeenCalledWith(expect.objectContaining({ error: 'raw string' })); + // Default mock errorHandler returns shouldRetry=false → notify fires after reject. + expect(jobTrackerClient.notify).toHaveBeenCalledWith(task.id); }); it('continues polling when queueClient.reject itself throws', async () => { @@ -180,6 +203,9 @@ describe('TaskPoller', () => { await poller.start(); expect(queueClient.reject).toHaveBeenCalledTimes(2); + // task1: reject failed → early return, no notify. task2: reject succeeded → notify fires. + expect(jobTrackerClient.notify).toHaveBeenCalledTimes(1); + expect(jobTrackerClient.notify).toHaveBeenCalledWith(task2.id); }); });