Skip to content
Merged
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
12 changes: 12 additions & 0 deletions src/autopilot/services/first2finish.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ export class First2FinishService {
}
}

async handleIterativePhaseClosed(challengeId: string): Promise<void> {
try {
await this.prepareNextIterativeReview(challengeId);
} catch (error) {
const err = error as Error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[💡 maintainability]
Consider adding more context to the error logging. While the error message and stack trace are logged, additional context such as the function name or parameters could aid in debugging.

this.logger.error(
`Failed to refresh iterative review submissions for challenge ${challengeId} after phase closure: ${err.message}`,
err.stack,
);
}
}

private async processFirst2FinishSubmission(
challenge: IChallenge,
submissionId?: string,
Expand Down
34 changes: 34 additions & 0 deletions src/autopilot/services/scheduler.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
PhaseTransitionPayload,
} from '../interfaces/autopilot.interface';
import { FinanceApiService } from '../../finance/finance-api.service';
import { First2FinishService } from './first2finish.service';
import { ITERATIVE_REVIEW_PHASE_NAME } from '../constants/review.constants';

type MockedMethod<T extends (...args: any[]) => any> = jest.Mock<
ReturnType<T>,
Expand Down Expand Up @@ -99,6 +101,7 @@ describe('SchedulerService (review phase deferral)', () => {
let resourcesService: jest.Mocked<ResourcesService>;
let phaseChangeNotificationService: jest.Mocked<PhaseChangeNotificationService>;
let configService: jest.Mocked<ConfigService>;
let first2FinishService: jest.Mocked<First2FinishService>;

beforeEach(() => {
kafkaService = {
Expand Down Expand Up @@ -182,6 +185,10 @@ describe('SchedulerService (review phase deferral)', () => {
get: jest.fn().mockReturnValue(undefined),
} as unknown as jest.Mocked<ConfigService>;

first2FinishService = {
handleIterativePhaseClosed: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<First2FinishService>;

scheduler = new SchedulerService(
kafkaService as unknown as KafkaService,
challengeApiService as unknown as ChallengeApiService,
Expand All @@ -192,6 +199,7 @@ describe('SchedulerService (review phase deferral)', () => {
resourcesService,
phaseChangeNotificationService,
configService,
first2FinishService,
);
});

Expand Down Expand Up @@ -274,6 +282,32 @@ describe('SchedulerService (review phase deferral)', () => {
);
});

it('refreshes submissions when iterative review closes', async () => {
const payload = createPayload({
phaseTypeName: ITERATIVE_REVIEW_PHASE_NAME,
});
const phaseDetails = createPhase({
id: payload.phaseId,
phaseId: payload.phaseId,
name: ITERATIVE_REVIEW_PHASE_NAME,
isOpen: true,
});

challengeApiService.getPhaseDetails.mockResolvedValue(phaseDetails);
reviewService.getPendingReviewCount.mockResolvedValue(0);
challengeApiService.advancePhase.mockResolvedValue({
success: true,
message: 'closed iterative review',
updatedPhases: [],
});

await scheduler.advancePhase(payload);

expect(
first2FinishService.handleIterativePhaseClosed,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[⚠️ correctness]
Consider checking if first2FinishService.handleIterativePhaseClosed resolves successfully or handles potential errors. This will ensure that any issues during the handling of the iterative phase closure are caught and managed appropriately.

).toHaveBeenCalledWith(payload.challengeId);
});

it('assigns checkpoint winners after closing checkpoint review', async () => {
const payload = createPayload({
phaseId: 'checkpoint-phase',
Expand Down
17 changes: 17 additions & 0 deletions src/autopilot/services/scheduler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
APPROVAL_PHASE_NAMES,
SUBMISSION_PHASE_NAME,
TOPGEAR_SUBMISSION_PHASE_NAME,
ITERATIVE_REVIEW_PHASE_NAME,
getRoleNamesForPhase,
isPostMortemPhaseName,
} from '../constants/review.constants';
Expand All @@ -45,6 +46,7 @@ import {
getMemberReviewerConfigs,
getReviewerConfigsForPhase,
} from '../utils/reviewer.utils';
import { First2FinishService } from './first2finish.service';

const PHASE_QUEUE_NAME = 'autopilot-phase-transitions';
const PHASE_QUEUE_PREFIX = '{autopilot-phase-transitions}';
Expand Down Expand Up @@ -108,6 +110,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
private readonly resourcesService: ResourcesService,
private readonly phaseChangeNotificationService: PhaseChangeNotificationService,
private readonly configService: ConfigService,
private readonly first2FinishService: First2FinishService,
) {
this.submitterRoles = getNormalizedStringArray(
this.configService.get('autopilot.submitterRoles'),
Expand Down Expand Up @@ -861,6 +864,20 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
);
}

if (operation === 'close' && phaseName === ITERATIVE_REVIEW_PHASE_NAME) {
try {
await this.first2FinishService.handleIterativePhaseClosed(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[⚠️ maintainability]
Consider adding a retry mechanism for this.first2FinishService.handleIterativePhaseClosed to handle transient errors more gracefully. This would improve the robustness of the phase closure process.

data.challengeId,
);
} catch (error) {
const err = error as Error;
this.logger.error(
`Failed to refresh iterative submissions for challenge ${data.challengeId} after closing phase ${data.phaseId}: ${err.message}`,
err.stack,
);
}
}

if (operation === 'open') {
try {
await this.phaseReviewService.handlePhaseOpened(
Expand Down
113 changes: 113 additions & 0 deletions src/health/kafka.health.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { HealthCheckError } from '@nestjs/terminus';
import { KafkaHealthIndicator } from './kafka.health';
import {
KafkaConnectionState,
KafkaService,
} from '../kafka/kafka.service';

jest.mock('../kafka/kafka.service', () => {
const KafkaConnectionStateMock = {
initializing: 'initializing',
ready: 'ready',
reconnecting: 'reconnecting',
failed: 'failed',
disabled: 'disabled',
} as const;

class KafkaServiceMock {
isConnected = jest.fn();
getKafkaStatus = jest.fn();
}

return {
KafkaConnectionState: KafkaConnectionStateMock,
KafkaService: KafkaServiceMock,
};
});

jest.mock('@platformatic/kafka', () => {
class MockConsumer {
consume = jest.fn();
isConnected = jest.fn().mockReturnValue(true);
close = jest.fn();
on = jest.fn();
}

class MockProducer {
metadata = jest.fn();
send = jest.fn();
close = jest.fn();
isConnected = jest.fn().mockReturnValue(true);
}

return {
Consumer: MockConsumer,
Producer: MockProducer,
MessagesStream: class {},
ProduceAcks: { ALL: 'all' },
jsonDeserializer: jest.fn(),
jsonSerializer: jest.fn(),
stringDeserializer: jest.fn(),
stringSerializer: jest.fn(),
};
});

describe('KafkaHealthIndicator', () => {
let kafkaService: jest.Mocked<
Pick<KafkaService, 'isConnected' | 'getKafkaStatus'>
>;
let indicator: KafkaHealthIndicator;

beforeEach(() => {
kafkaService = {
isConnected: jest.fn(),
getKafkaStatus: jest.fn(),
} as unknown as jest.Mocked<
Pick<KafkaService, 'isConnected' | 'getKafkaStatus'>
>;

indicator = new KafkaHealthIndicator(
kafkaService as unknown as KafkaService,
);
});

it('returns a healthy status when Kafka is connected', async () => {
kafkaService.getKafkaStatus.mockReturnValue({
state: KafkaConnectionState.ready,
reconnectAttempts: 0,
});
kafkaService.isConnected.mockResolvedValue(true);

const result = await indicator.isHealthy('kafka');

expect(result.kafka.status).toBe('up');
expect(result.kafka.state).toBe(KafkaConnectionState.ready);
expect(result.kafka.reconnectAttempts).toBe(0);
});

it('throws when Kafka state is failed', async () => {
kafkaService.getKafkaStatus.mockReturnValue({
state: KafkaConnectionState.failed,
reconnectAttempts: 3,
reason: 'Kafka reconnection attempts exhausted',
});

await expect(indicator.isHealthy('kafka')).rejects.toBeInstanceOf(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[💡 performance]
Consider checking the state before calling isConnected to avoid unnecessary calls when the state is already failed. This can improve performance slightly by reducing unnecessary operations.

HealthCheckError,
);
expect(kafkaService.isConnected).not.toHaveBeenCalled();
});

it('throws when Kafka connections are not ready', async () => {
kafkaService.getKafkaStatus.mockReturnValue({
state: KafkaConnectionState.ready,
reconnectAttempts: 1,
reason: 'Kafka is not connected',
});
kafkaService.isConnected.mockResolvedValue(false);

await expect(indicator.isHealthy('kafka')).rejects.toBeInstanceOf(
HealthCheckError,
);
});
});
39 changes: 30 additions & 9 deletions src/health/kafka.health.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthCheckError } from '@nestjs/terminus';
import { KafkaService } from '../kafka/kafka.service';
import {
KafkaConnectionState,
KafkaService,
} from '../kafka/kafka.service';
import { LoggerService } from '../common/services/logger.service';

@Injectable()
Expand All @@ -13,22 +16,40 @@ export class KafkaHealthIndicator extends HealthIndicator {

async isHealthy(key: string) {
try {
const status = this.kafkaService.getKafkaStatus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[⚠️ correctness]
Consider handling the case where getKafkaStatus() might return undefined or an unexpected structure. This could prevent potential runtime errors if the method's contract changes or if there's an unexpected failure in fetching the status.

const timestamp = new Date().toISOString();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[💡 maintainability]
The timestamp is being generated multiple times in the method. Consider generating it once at the start of the method and reusing it to ensure consistency across all log entries and error messages.


if (status.state === KafkaConnectionState.failed) {
throw new HealthCheckError(
'KafkaHealthCheck failed',
this.getStatus(key, false, {
state: status.state,
reconnectAttempts: status.reconnectAttempts,
reason:
status.reason || 'Kafka reconnection attempts exhausted',
timestamp,
}),
);
}

const isConnected = await this.kafkaService.isConnected();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[💡 design]
The isConnected check could potentially be combined with the status.state check to streamline the health check logic. Consider whether both checks are necessary or if they can be unified to simplify the control flow.


if (!isConnected) {
throw new HealthCheckError(
'KafkaHealthCheck failed',
this.getStatus(key, false, { error: 'Kafka is not connected' }),
this.getStatus(key, false, {
state: status.state,
reconnectAttempts: status.reconnectAttempts,
reason: status.reason || 'Kafka is not connected',
timestamp,
}),
);
}

return this.getStatus(key, true, {
status: 'up',
timestamp: new Date().toISOString(),
details: {
producer: 'connected',
consumers: 'active',
},
state: status.state,
reconnectAttempts: status.reconnectAttempts,
timestamp,
});
} catch (error: unknown) {
const err = error as Error;
Expand All @@ -41,7 +62,7 @@ export class KafkaHealthIndicator extends HealthIndicator {
throw new HealthCheckError(
'KafkaHealthCheck failed',
this.getStatus(key, false, {
error: err.message,
reason: err.message,
timestamp: new Date().toISOString(),
}),
);
Expand Down
Loading