Skip to content

Commit

Permalink
feature(api): add service for jobs status
Browse files Browse the repository at this point in the history
  • Loading branch information
Dyostiq authored and kgajowy committed Jun 11, 2021
1 parent 411de83 commit 57411c9
Show file tree
Hide file tree
Showing 9 changed files with 343 additions and 25 deletions.
33 changes: 33 additions & 0 deletions api/apps/api/src/migrations/api/1623311716713-AddJobStatusView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddJobStatusView1623311716713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE VIEW "scenario_job_status" AS
SELECT
DISTINCT ON ("jobType", topic) "jobType",
api_events.topic AS "scenarioId",
projects.id AS "projectId",
api_events.kind
FROM
api_events
INNER JOIN scenarios ON api_events.topic = scenarios.id
INNER JOIN projects ON projects.id = scenarios.project_id
CROSS JOIN LATERAL SUBSTRING(
api_events.kind
FROM
'scenario.#"[^.]*#"%' FOR '#'
) AS "jobType"
ORDER BY
"jobType",
api_events.topic,
api_events.timestamp DESC;
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP VIEW "scenario_job_status";
`);
}
}
50 changes: 27 additions & 23 deletions api/apps/api/src/modules/projects/job-status/job-status.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ApiEventsService } from '@marxan-api/modules/api-events/api-events.service';
import { ScenariosService } from '@marxan-api/modules/scenarios/scenarios.service';
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { JobStatus as Status } from '@marxan-api/modules/scenarios/scenario.api.entity';
import { assertDefined } from '@marxan/utils';
import { ScenarioJobStatus } from './job-status.view.api.entity';
import { JobType } from './jobs.enum';

export { Status };
Expand All @@ -13,33 +15,35 @@ export interface Job {

export interface Scenario {
scenarioId: string;
status: Status;
jobs: Job[];
}

@Injectable()
export class JobStatusService {
constructor(
private readonly apiEvents: ApiEventsService,
private readonly scenariosService: ScenariosService,
@InjectRepository(ScenarioJobStatus)
private readonly statusRepository: Repository<ScenarioJobStatus>,
) {}

/**
* @throws NotFoundException
*/
async getJobStatusFor(_projectId: string): Promise<Scenario[]> {
return [];
// get status of project job(s) ?
// get all scenarios for given project
// for each scenario, find its jobs and relevant statuses
/**
*
* Draft of SQL
*
* SELECT kind, topic, MAX(timestamp) from api_events where
--topic in ('03fb678e-689b-473c-af80-6915685a53a8', 'ce2069ee-2925-4c73-a328-882447e6c84d') and
( kind like 'project.protectedAreas%' or kind like 'user.account%' )
group by kind, topic
*/
async getJobStatusFor(projectId: string): Promise<Scenario[]> {
const statuses = await this.statusRepository.find({
projectId,
});
type ScenarioId = string;
const groupedStatuses: Record<ScenarioId, Scenario> = {};
for (const status of statuses) {
groupedStatuses[status.scenarioId] ??= {
scenarioId: status.scenarioId,
jobs: [],
};
const jobStatus = status.jobStatus;
assertDefined(jobStatus);
groupedStatuses[status.scenarioId].jobs.push({
kind: status.jobType,
status: jobStatus,
});
}

return Object.values(groupedStatuses);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ViewColumn, ViewEntity } from 'typeorm';
import { JobType } from '@marxan-api/modules/projects/job-status/jobs.enum';
import { JobStatus } from '@marxan-api/modules/scenarios/scenario.api.entity';
import { API_EVENT_KINDS } from '@marxan/api-events';

@ViewEntity({
expression: `
SELECT
DISTINCT ON ("jobType", topic) "jobType",
api_events.topic AS "scenarioId",
projects.id AS "projectId",
api_events.kind
FROM
api_events
INNER JOIN scenarios ON api_events.topic = scenarios.id
INNER JOIN projects ON projects.id = scenarios.project_id
CROSS JOIN LATERAL SUBSTRING(
api_events.kind
FROM
'scenario.#"[^.]*#"%' FOR '#'
) AS "jobType"
ORDER BY
"jobType",
api_events.topic,
api_events.timestamp DESC;
`,
})
export class ScenarioJobStatus {
@ViewColumn()
jobType!: JobType;

@ViewColumn()
kind!: API_EVENT_KINDS;

get jobStatus(): JobStatus | undefined {
// I didn't use CASE ... THEN in the SQL as I wanted to enforce the compiler check on the mapping
return eventToJobStatusMapping[this.kind];
}

@ViewColumn()
scenarioId!: string;

@ViewColumn()
projectId!: string;
}

const eventToJobStatusMapping: Record<
API_EVENT_KINDS,
JobStatus | undefined
> = {
[API_EVENT_KINDS.project__protectedAreas__failed__v1__alpha]: undefined,
[API_EVENT_KINDS.project__protectedAreas__finished__v1__alpha]: undefined,
[API_EVENT_KINDS.project__protectedAreas__submitted__v1__alpha]: undefined,
[API_EVENT_KINDS.scenario__costSurface__costUpdateFailed__v1_alpha1]:
JobStatus.failure,
[API_EVENT_KINDS.scenario__costSurface__finished__v1_alpha1]: JobStatus.done,
[API_EVENT_KINDS.scenario__costSurface__shapeConversionFailed__v1_alpha1]:
JobStatus.failure,
[API_EVENT_KINDS.scenario__costSurface__shapeConverted__v1_alpha1]:
JobStatus.running,
[API_EVENT_KINDS.scenario__costSurface__submitted__v1_alpha1]:
JobStatus.running,
[API_EVENT_KINDS.scenario__planningUnitsInclusion__failed__v1__alpha1]:
JobStatus.failure,
[API_EVENT_KINDS.scenario__planningUnitsInclusion__finished__v1__alpha1]:
JobStatus.done,
[API_EVENT_KINDS.scenario__planningUnitsInclusion__submitted__v1__alpha1]:
JobStatus.running,
[API_EVENT_KINDS.user__accountActivationFailed__v1alpha1]: undefined,
[API_EVENT_KINDS.user__accountActivationSucceeded__v1alpha1]: undefined,
[API_EVENT_KINDS.user__accountActivationTokenGenerated__v1alpha1]: undefined,
[API_EVENT_KINDS.user__passwordResetFailed__v1alpha1]: undefined,
[API_EVENT_KINDS.user__passwordResetSucceeded__v1alpha1]: undefined,
[API_EVENT_KINDS.user__passwordResetTokenGenerated__v1alpha1]: undefined,
[API_EVENT_KINDS.user__signedUp__v1alpha1]: undefined,
};
3 changes: 2 additions & 1 deletion api/apps/api/src/modules/projects/projects.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ import { ProjectSerializer } from './dto/project.serializer';
import { JobStatusSerializer } from './dto/job-status.serializer';
import { BboxResolver } from './bbox/bbox-resolver';
import { JobStatusService } from './job-status/job-status.service';
import { ScenarioJobStatus } from './job-status/job-status.view.api.entity';

@Module({
imports: [
AdminAreasModule,
CountriesModule,
GeoFeaturesModule,
forwardRef(() => ScenariosModule),
TypeOrmModule.forFeature([Project]),
TypeOrmModule.forFeature([Project, ScenarioJobStatus]),
UsersModule,
PlanningUnitsModule,
ProtectedAreasModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { PromiseType } from 'utility-types';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ApiEventsService } from '@marxan-api/modules/api-events/api-events.service';
import { OrganizationsService } from '@marxan-api/modules/organizations/organizations.service';
import { ProjectsCrudService } from '@marxan-api/modules/projects/projects-crud.service';
import { ScenariosCrudService } from '@marxan-api/modules/scenarios/scenarios-crud.service';
import { Scenario as ScenarioEntity } from '@marxan-api/modules/scenarios/scenario.api.entity';
import { API_EVENT_KINDS } from '@marxan/api-events';
import { ScenarioJobStatus } from '@marxan-api/modules/projects/job-status/job-status.view.api.entity';
import { CreateOrganizationDTO } from '@marxan-api/modules/organizations/dto/create.organization.dto';
import { CreateProjectDTO } from '@marxan-api/modules/projects/dto/create.project.dto';
import { CreateScenarioDTO } from '@marxan-api/modules/scenarios/dto/create.scenario.dto';
import { Project } from '@marxan-api/modules/projects/project.api.entity';
import { ApiEvent } from '@marxan-api/modules/api-events/api-event.api.entity';
import {
JobStatusService,
Scenario,
} from '@marxan-api/modules/projects/job-status';
import { E2E_CONFIG } from '../e2e.config';
import { bootstrapApplication } from '../utils/api-application';

let fixtures: PromiseType<ReturnType<typeof getFixtures>>;

beforeEach(async () => {
fixtures = await getFixtures();
});

afterEach(async () => {
await fixtures.cleanup();
});

describe(`when has two projects with scenarios and events`, () => {
let result: Scenario[];
let scenarioIds: string[];
let projectId: string;
beforeEach(async () => {
({
scenarioIds,
projectId,
} = await fixtures.givenProjectWithTwoScenariosWithOverridingEvents());
await fixtures.givenAnotherProjectWithAScenarioWithAnEvent();

result = await fixtures.getJobStatusService().getJobStatusFor(projectId);
});

it(`should return last events of every job for scenarios of the project`, () => {
expect(result).toEqual(
[
{
jobs: [
{
kind: 'costSurface',
status: 'done',
},
{
kind: 'planningUnitsInclusion',
status: 'failure',
},
],
scenarioId: scenarioIds[0],
},
{
jobs: [
{
kind: 'costSurface',
status: 'running',
},
],
scenarioId: scenarioIds[1],
},
].sort((a, b) => a.scenarioId.localeCompare(b.scenarioId)),
);
});
});

async function getFixtures() {
const application = await bootstrapApplication();

const eventsRepository = application.get(ApiEventsService);
const organizationRepository = application.get(OrganizationsService);
const organization = await organizationRepository.create(
E2E_CONFIG.organizations.valid.minimal() as CreateOrganizationDTO,
);
const projectRepository = application.get(ProjectsCrudService);
const addedProjects: Project[] = [];
const scenarioRepository = application.get(ScenariosCrudService);
const addedScenarios: ScenarioEntity[] = [];

const addedEvents: ApiEvent[] = [];

const statusRepository: Repository<ScenarioJobStatus> = application.get(
getRepositoryToken(ScenarioJobStatus),
);

const fixtures = {
async givenProjectWithTwoScenariosWithOverridingEvents() {
const project = await projectRepository.create({
...(E2E_CONFIG.projects.valid.minimal() as CreateProjectDTO),
organizationId: organization.id,
});
const scenario1 = await scenarioRepository.create({
...(E2E_CONFIG.scenarios.valid.minimal() as CreateScenarioDTO),
projectId: project.id,
});
const scenario2 = await scenarioRepository.create({
...(E2E_CONFIG.scenarios.valid.minimal() as CreateScenarioDTO),
projectId: project.id,
});
const eventDtos = [
{
kind:
API_EVENT_KINDS.scenario__costSurface__costUpdateFailed__v1_alpha1,
topic: scenario1.id,
},
{
kind: API_EVENT_KINDS.scenario__costSurface__finished__v1_alpha1,
topic: scenario1.id,
},
{
kind:
API_EVENT_KINDS.scenario__planningUnitsInclusion__submitted__v1__alpha1,
topic: scenario1.id,
},
{
kind:
API_EVENT_KINDS.scenario__planningUnitsInclusion__failed__v1__alpha1,
topic: scenario1.id,
},
{
kind: API_EVENT_KINDS.scenario__costSurface__submitted__v1_alpha1,
topic: scenario2.id,
},
];
for (const eventDto of eventDtos) {
const event = await eventsRepository.create(eventDto);
addedEvents.push(event);
}
addedProjects.push(project);
addedScenarios.push(scenario1, scenario2);
return {
projectId: project.id,
scenarioIds: [scenario1.id, scenario2.id],
};
},
async givenAnotherProjectWithAScenarioWithAnEvent() {
const project = await projectRepository.create({
...(E2E_CONFIG.projects.valid.minimal() as CreateProjectDTO),
organizationId: organization.id,
});
const scenario = await scenarioRepository.create({
...(E2E_CONFIG.scenarios.valid.minimal() as CreateScenarioDTO),
projectId: project.id,
});
const event = await eventsRepository.create({
kind:
API_EVENT_KINDS.scenario__planningUnitsInclusion__finished__v1__alpha1,
topic: project.id,
});
addedProjects.push(project);
addedScenarios.push(scenario);
addedEvents.push(event);
},
async cleanup() {
await eventsRepository.repo.remove(addedEvents);
await Promise.all(
addedScenarios.map((scenario) =>
scenarioRepository.remove(scenario.id),
),
);
await Promise.all(
addedProjects.map((project) => projectRepository.remove(project.id)),
);
await organizationRepository.remove(organization.id);
},
getStatusRepository() {
return statusRepository;
},
getJobStatusService() {
return application.get(JobStatusService);
},
};
return fixtures;
}
Loading

0 comments on commit 57411c9

Please sign in to comment.