Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ConflictException } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { CatalogEtlService } from '../catalog-etl.service';
import { CatalogEtlScheduler } from './catalog-etl.scheduler';

const mockRunStep = jest.fn();
const mockGetLastSuccessfulStepRun = jest.fn();
const mockLogger = {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
} as unknown as PinoLogger;

const catalogEtlService = {
runStep: mockRunStep,
getLastSuccessfulStepRun: mockGetLastSuccessfulStepRun,
} as unknown as CatalogEtlService;

function makeScheduler(): CatalogEtlScheduler {
return new CatalogEtlScheduler(mockLogger, catalogEtlService);
}

beforeEach(() => {
jest.clearAllMocks();
mockGetLastSuccessfulStepRun.mockResolvedValue(null);
});

describe('CatalogEtlScheduler.scheduledTerminalEtl', () => {
it('runs terminals-sync then terminal-distances-sync on success', async () => {
mockRunStep.mockResolvedValue(undefined);
await makeScheduler().scheduledTerminalEtl();
expect(mockRunStep).toHaveBeenCalledTimes(2);
expect(mockRunStep).toHaveBeenNthCalledWith(1, 'terminals-sync');
expect(mockRunStep).toHaveBeenNthCalledWith(2, 'terminal-distances-sync');
});

it('skips terminal-distances-sync when terminals-sync throws a non-conflict error', async () => {
mockRunStep.mockRejectedValueOnce(new Error('db connection lost'));
await makeScheduler().scheduledTerminalEtl();
expect(mockRunStep).toHaveBeenCalledTimes(1);
expect(mockRunStep).toHaveBeenCalledWith('terminals-sync');
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'terminals-sync failed; skipping terminal-distances-sync',
);
});

it('skips both steps when terminals-sync throws ConflictException', async () => {
mockRunStep.mockRejectedValueOnce(new ConflictException());
await makeScheduler().scheduledTerminalEtl();
expect(mockRunStep).toHaveBeenCalledTimes(1);
expect(mockLogger.debug).toHaveBeenCalled();
expect(mockLogger.error).not.toHaveBeenCalled();
});

it('logs error but does not throw when terminal-distances-sync fails', async () => {
mockRunStep
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('distances failed'));
await expect(makeScheduler().scheduledTerminalEtl()).resolves.not.toThrow();
expect(mockRunStep).toHaveBeenCalledTimes(2);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'terminal-distances-sync failed',
);
});

it('skips when terminals-sync was completed within SKIP_HOURS', async () => {
mockGetLastSuccessfulStepRun.mockResolvedValue(new Date().toISOString());
await makeScheduler().scheduledTerminalEtl();
expect(mockRunStep).not.toHaveBeenCalled();
});
Comment on lines +68 to +72

it('skips terminal-distances-sync when it was completed within SKIP_HOURS', async () => {
mockGetLastSuccessfulStepRun
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(new Date().toISOString());
mockRunStep.mockResolvedValue(undefined);
await makeScheduler().scheduledTerminalEtl();
expect(mockRunStep).toHaveBeenCalledTimes(1);
expect(mockRunStep).toHaveBeenCalledWith('terminals-sync');
});
});
25 changes: 13 additions & 12 deletions backend/src/modules/catalog-etl/schedulers/catalog-etl.scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,41 @@ export class CatalogEtlScheduler {
private readonly catalogEtlService: CatalogEtlService,
) {}

@Cron('0 * * * *', { name: 'terminals-sync' })
async scheduledTerminalsSync(): Promise<void> {
@Cron('0 * * * *', { name: 'terminal-etl' })
async scheduledTerminalEtl(): Promise<void> {
if (await this.shouldSkip('terminals-sync')) return;
this.logger.info('Starting scheduled terminals sync');
this.logger.info('Starting scheduled terminal ETL');

Comment on lines +17 to +20
try {
await this.catalogEtlService.runStep('terminals-sync');
} catch (err: unknown) {
if (err instanceof ConflictException) {
this.logger.debug(
{ err },
'Scheduled terminals sync skipped: ETL lock already held',
'Scheduled terminal ETL skipped: ETL lock already held',
);
return;
}
this.logger.error({ err }, 'Scheduled terminals sync failed');
this.logger.error(
{ err },
'terminals-sync failed; skipping terminal-distances-sync',
);
return;
}
}

// Runs 5 minutes after terminals-sync to ensure station_terminal is populated first
@Cron('5 * * * *', { name: 'terminal-distances-sync' })
async scheduledTerminalDistancesSync(): Promise<void> {
if (await this.shouldSkip('terminal-distances-sync')) return;
this.logger.info('Starting scheduled terminal distances sync');

try {
await this.catalogEtlService.runStep('terminal-distances-sync');
} catch (err: unknown) {
if (err instanceof ConflictException) {
this.logger.debug(
{ err },
'Scheduled terminal distances sync skipped: ETL lock already held',
'terminal-distances-sync skipped: ETL lock already held',
);
return;
}
this.logger.error({ err }, 'Scheduled terminal distances sync failed');
this.logger.error({ err }, 'terminal-distances-sync failed');
}
}

Expand Down
Loading