Skip to content

Commit 9250763

Browse files
committed
HARMONY-1963: Add tests for size change info in job status
1 parent 33b3f55 commit 9250763

File tree

5 files changed

+222
-28
lines changed

5 files changed

+222
-28
lines changed

services/harmony/app/frontends/jobs.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,58 @@ export async function getJobsListing(
172172
}
173173
}
174174

175+
/**
176+
* Get a message explaining the change in size from the input to the output
177+
*
178+
* @param sizes - original and output sizes of the input in MiB (1024 x 1024 bytes)
179+
* @param precision - the number of decimal places to allow in the output
180+
* @returns a message explaining the size change as a percentage
181+
*/
182+
export function sizeChangeMessage(
183+
sizes: { originalSize: number; outputSize: number; },
184+
precision: number = 2): string {
185+
if (sizes.originalSize === 0) {
186+
return 'Original size is 0 - percent size change N/A';
187+
}
188+
if (sizes.outputSize === 0) {
189+
return 'Output size is 0 - percent size change N/A';
190+
}
191+
let result: string;
192+
const diff = sizes.originalSize - sizes.outputSize;
193+
if (diff < 0) {
194+
const percent = (-diff / sizes.originalSize * 100.0).toFixed(precision);
195+
result = `${percent}% increase`;
196+
} else if (diff > 0) {
197+
let percent = (diff / sizes.originalSize * 100.0).toFixed(precision);
198+
// due to JS precision issues, big changes will appear to be 100% reduction, which is impossible
199+
if (percent === '100.00') percent = '99.99';
200+
201+
result = `${percent}% reduction`;
202+
} else {
203+
result = 'no change';
204+
}
205+
206+
return result;
207+
}
208+
209+
/**
210+
* Format a data size number as a string for human presentation
211+
* @param mibSize - the float size in MiB (1024x1024 bytes)
212+
* @param precision - the number of decimal places to allow in the output
213+
* @returns a string representing the size using B, KiB, MiB, etc., notation
214+
*/
215+
export function formatDataSize(mibSize: number, precision: number = 2): string {
216+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
217+
let size = mibSize * 1024 * 1024;
218+
let unitIndex = 0;
219+
while (size >= 1024 && unitIndex < units.length - 1) {
220+
size /= 1024;
221+
unitIndex++;
222+
}
223+
224+
return `${size.toFixed(precision)} ${units[unitIndex]}`;
225+
}
226+
175227
/**
176228
* Express.js handler that returns job status for a single job `(/jobs/{jobID})`
177229
*
@@ -190,23 +242,18 @@ export async function getJobStatus(
190242
try {
191243
validateJobId(jobID);
192244
const { page, limit } = getPagingParams(req, env.defaultResultPageSize);
193-
let job: Job;
194-
let pagination;
195-
let messages: JobMessage[];
196245
let workItemsSizes: { originalSize: number; outputSize: number; };
197246

198-
// TODO does this really need to be in a transaction?
199-
await db.transaction(async (tx) => {
200-
({ job, pagination } = await Job.byJobID(tx, jobID, true, true, false, page, limit));
201-
messages = await getMessagesForJob(tx, jobID);
202-
// only get data reduction numbers when the job is complete and at least partially successful
203-
if ([JobStatus.SUCCESSFUL, JobStatus.COMPLETE_WITH_ERRORS].includes(job.status)) {
204-
workItemsSizes = await getTotalWorkItemSizesForJobID(tx, jobID);
205-
}
206-
});
247+
const { job, pagination } = await Job.byJobID(db, jobID, true, true, false, page, limit);
207248
if (!job) {
208249
throw new NotFoundError(`Unable to find job ${jobID}`);
209250
}
251+
const messages: JobMessage[] = await getMessagesForJob(db, jobID);
252+
// only get data reduction numbers when the job is complete and at least partially successful
253+
if ([JobStatus.SUCCESSFUL, JobStatus.COMPLETE_WITH_ERRORS].includes(job.status)) {
254+
workItemsSizes = await getTotalWorkItemSizesForJobID(db, jobID);
255+
}
256+
210257
const isAdmin = await isAdminUser(req);
211258
const isAdminOrOwner = job.belongsToOrIsAdmin(req.user, isAdmin);
212259
const isJobShareable = await job.isShareable(req.accessToken);
@@ -218,8 +265,9 @@ export async function getJobStatus(
218265
job.links = job.links.concat(pagingLinks);
219266
const jobForDisplay = getJobForDisplay(job, urlRoot, linkType, messages);
220267
if (workItemsSizes) {
221-
jobForDisplay.originalDataSize = workItemsSizes.originalSize;
222-
jobForDisplay.outputDataSize = workItemsSizes.outputSize;
268+
jobForDisplay.originalDataSize = formatDataSize(workItemsSizes.originalSize);
269+
jobForDisplay.outputDataSize = formatDataSize(workItemsSizes.outputSize);
270+
jobForDisplay.dataSizePercentChange = sizeChangeMessage(workItemsSizes);
223271
}
224272
res.send(jobForDisplay);
225273
} catch (e) {

services/harmony/app/models/job.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,11 @@ export class JobForDisplay {
114114

115115
numInputGranules: number;
116116

117-
originalDataSize?: number;
117+
originalDataSize?: string;
118118

119-
outputDataSize?: number;
119+
outputDataSize?: string;
120+
121+
dataSizePercentChange?: string;
120122

121123
errors?: JobMessage[];
122124

services/harmony/app/models/work-item.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
/* eslint-disable @typescript-eslint/dot-notation */
22
import { subMinutes } from 'date-fns';
33
import { ILengthAwarePagination } from 'knex-paginate';
4-
import _ from 'lodash';
5-
import logger from '../util/log';
4+
import _, { last } from 'lodash';
5+
6+
import { getWorkSchedulerQueue } from '../../app/util/queue/queue-factory';
7+
import { eventEmitter } from '../events';
68
import db, { Transaction } from '../util/db';
7-
import DataOperation from './data-operation';
89
import env from '../util/env';
10+
import logger from '../util/log';
11+
import DataOperation from './data-operation';
912
import { Job, JobStatus } from './job';
1013
import Record from './record';
14+
import {
15+
getStacLocation, WorkItemQuery, WorkItemRecord, WorkItemStatus,
16+
} from './work-item-interface';
1117
import WorkflowStep from './workflow-steps';
12-
import { WorkItemRecord, WorkItemStatus, getStacLocation, WorkItemQuery } from './work-item-interface';
13-
import { eventEmitter } from '../events';
14-
import { getWorkSchedulerQueue } from '../../app/util/queue/queue-factory';
1518

1619
// The step index for the query-cmr task. Right now query-cmr only runs as the first step -
1720
// if this changes we will have to revisit this

services/harmony/test/helpers/work-items.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { Application } from 'express';
2+
import _ from 'lodash';
23
import { afterEach, beforeEach } from 'mocha';
34
import request, { Test } from 'supertest';
4-
import _ from 'lodash';
5+
6+
import { RecordConstructor } from '../../app/models/record';
57
import WorkItem from '../../app/models/work-item';
8+
import {
9+
getStacLocation, WorkItemRecord, WorkItemStatus,
10+
} from '../../app/models/work-item-interface';
611
import db, { Transaction } from '../../app/util/db';
12+
import { objectStoreForProtocol } from '../../app/util/object-store';
713
import { truncateAll } from './db';
814
import { hookBackendRequest } from './hooks';
9-
import { buildWorkflowStep, hookWorkflowStepCreation, hookWorkflowStepCreationEach } from './workflow-steps';
10-
import { RecordConstructor } from '../../app/models/record';
11-
import { WorkItemStatus, WorkItemRecord, getStacLocation } from '../../app/models/work-item-interface';
12-
import { objectStoreForProtocol } from '../../app/util/object-store';
15+
import {
16+
buildWorkflowStep, hookWorkflowStepCreation, hookWorkflowStepCreationEach,
17+
} from './workflow-steps';
1318

1419
export const exampleWorkItemProps = {
1520
jobID: '1',
@@ -45,7 +50,7 @@ export function buildWorkItem(fields: Partial<WorkItemRecord> = {}): WorkItem {
4550

4651
/**
4752
* Save a work item without validating or updating createdAt/updatedAt
48-
* @param tx - The transaction to use for saving the job
53+
* @param tx - The transaction to use for saving the work item
4954
* @param fields - The fields to save to the database, defaults to example values
5055
* @returns The saved work item
5156
* @throws Error - if the save to the database fails

services/harmony/test/jobs/jobs-status.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { stub } from 'sinon';
44
import request from 'supertest';
55
import { v4 as uuid } from 'uuid';
66

7+
import { formatDataSize, sizeChangeMessage } from '../../app/frontends/jobs';
78
import { EXPIRATION_DAYS, Job, JobStatus } from '../../app/models/job';
89
import JobMessage, { JobMessageLevel } from '../../app/models/job-message';
910
import { setLabelsForJob } from '../../app/models/label';
11+
import { WorkItemStatus } from '../../app/models/work-item-interface';
1012
import env from '../../app/util/env';
1113
import { hookDatabaseFailure, hookTransaction } from '../helpers/db';
1214
import { hookRedirect, hookUrl } from '../helpers/hooks';
@@ -19,6 +21,8 @@ import {
1921
import { hookRangesetRequest } from '../helpers/ogc-api-coverages';
2022
import hookServersStartStop from '../helpers/servers';
2123
import StubService from '../helpers/stub-service';
24+
import { buildWorkItem } from '../helpers/work-items';
25+
import { buildWorkflowStep } from '../helpers/workflow-steps';
2226

2327
const aJob = buildJob({ username: 'joe' });
2428
const pausedJob = buildJob({ username: 'joe' });
@@ -32,6 +36,7 @@ const warningMessage = new JobMessage({
3236
message_category: 'nodata',
3337
});
3438
const warningJob = buildJob({ username: 'joe', status: JobStatus.SUCCESSFUL, message: 'Service could not get data', messages: [warningMessage] });
39+
const successfulJob = buildJob({ username: 'joe', status: JobStatus.SUCCESSFUL, message: 'Success' });
3540

3641
const timeStampRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/;
3742

@@ -74,16 +79,39 @@ describe('Individual job status route', function () {
7479
hookTransaction();
7580
before(async function () {
7681
await aJob.save(this.trx);
82+
console.log(`JOB ID: ${aJob.jobID}`);
7783
await setLabelsForJob(this.trx, aJob.jobID, aJob.username, aJobLabels);
7884
await pausedJob.save(this.trx);
7985
await previewingJob.save(this.trx);
8086
await warningJob.save(this.trx);
87+
await successfulJob.save(this.trx);
88+
const initialSuccessJobWorkflowStep = buildWorkflowStep({ jobID: successfulJob.jobID, stepIndex: 1 });
89+
await initialSuccessJobWorkflowStep.save(this.trx);
90+
const finalSuccessJobWorkflowStep = buildWorkflowStep({ jobID: successfulJob.jobID, stepIndex: 2 });
91+
await finalSuccessJobWorkflowStep.save(this.trx);
92+
const initialWarningJobWorkflowStep = buildWorkflowStep({ jobID: warningJob.jobID, stepIndex: 1 });
93+
await initialWarningJobWorkflowStep.save(this.trx);
94+
const finalWarningJobWorkflowStep = buildWorkflowStep({ jobID: warningJob.jobID, stepIndex: 2 });
95+
await finalWarningJobWorkflowStep.save(this.trx);
96+
const initialSuccessJobWorkItem1 = buildWorkItem({ jobID: successfulJob.jobID, workflowStepIndex: 1, status: WorkItemStatus.SUCCESSFUL, totalItemsSize: 1.3701868057250977 });
97+
await initialSuccessJobWorkItem1.save(this.trx);
98+
const finalSuccessJobWorkItem1 = buildWorkItem({ jobID: successfulJob.jobID, workflowStepIndex: 2, status: WorkItemStatus.WARNING, totalItemsSize: 0.09036064147949219 });
99+
await finalSuccessJobWorkItem1.save(this.trx);
100+
const initialSuccessJobWorkItem2 = buildWorkItem({ jobID: successfulJob.jobID, workflowStepIndex: 1, status: WorkItemStatus.SUCCESSFUL, totalItemsSize: 1.7018680517250977 });
101+
await initialSuccessJobWorkItem2.save(this.trx);
102+
const finalSuccessJobWorkItem2 = buildWorkItem({ jobID: successfulJob.jobID, workflowStepIndex: 2, status: WorkItemStatus.WARNING, totalItemsSize: 0.09361064147949219 });
103+
await finalSuccessJobWorkItem2.save(this.trx);
104+
const initialWarningJobWorkItem = buildWorkItem({ jobID: warningJob.jobID, workflowStepIndex: 1, status: WorkItemStatus.SUCCESSFUL, totalItemsSize: 1.3701868057250977 });
105+
await initialWarningJobWorkItem.save(this.trx);
106+
const finalWarningJobWorkItem = buildWorkItem({ jobID: warningJob.jobID, workflowStepIndex: 2, status: WorkItemStatus.WARNING, totalItemsSize: 0.09036064147949219 });
107+
await finalWarningJobWorkItem.save(this.trx);
81108
this.trx.commit();
82109
});
83110
const jobID = aJob.requestId;
84111
const pausedjobID = pausedJob.requestId;
85112
const previewingJobID = previewingJob.requestId;
86113
const warningJobID = warningJob.requestId;
114+
const successJobID = successfulJob.requestId;
87115

88116
describe('For a user who is not logged in', function () {
89117
before(async function () {
@@ -150,6 +178,13 @@ describe('Individual job status route', function () {
150178
expect(job.labels).deep.equal(['000', 'bar', 'foo', 'z-label']);
151179
});
152180

181+
it('does not include data size reduction information', function () {
182+
const job = JSON.parse(this.res.text);
183+
expect(job.originalDataSize).to.be.undefined;
184+
expect(job.outputDataSize).to.be.undefined;
185+
expect(job.dataSizePercentChange).to.be.undefined;
186+
});
187+
153188
itIncludesADataExpirationField();
154189
});
155190
}
@@ -173,6 +208,13 @@ describe('Individual job status route', function () {
173208
expect(resumeLinks.length).to.equal(0);
174209
});
175210

211+
it('does not include data size reduction information', function () {
212+
const job = JSON.parse(this.res.text);
213+
expect(job.originalDataSize).to.be.undefined;
214+
expect(job.outputDataSize).to.be.undefined;
215+
expect(job.dataSizePercentChange).to.be.undefined;
216+
});
217+
176218
itIncludesADataExpirationField();
177219
});
178220

@@ -205,6 +247,52 @@ describe('Individual job status route', function () {
205247
expect(previewSkipLinks.length).to.equal(0);
206248
});
207249

250+
it('does not include data size reduction information', function () {
251+
const job = JSON.parse(this.res.text);
252+
expect(job.originalDataSize).to.be.undefined;
253+
expect(job.outputDataSize).to.be.undefined;
254+
expect(job.dataSizePercentChange).to.be.undefined;
255+
});
256+
257+
itIncludesADataExpirationField();
258+
});
259+
260+
describe('when the job is successful', function () {
261+
hookJobStatus({ jobID: successJobID, username: 'joe' });
262+
263+
it('returns a status field of "successful"', function () {
264+
const job = JSON.parse(this.res.text);
265+
expect(job.status).to.eql('successful');
266+
});
267+
268+
it('returns a human-readable message field corresponding to its state', function () {
269+
const job = JSON.parse(this.res.text);
270+
expect(job.message).to.include('Success');
271+
});
272+
273+
it('does not include links for canceling and resuming the job', function () {
274+
const job = new Job(JSON.parse(this.res.text));
275+
const resumeLinks = job.getRelatedLinks('resumer');
276+
expect(resumeLinks.length).to.equal(0);
277+
const cancelLinks = job.getRelatedLinks('canceler');
278+
expect(cancelLinks.length).to.equal(0);
279+
});
280+
281+
it('does not include irrelevant state change links', function () {
282+
const job = new Job(JSON.parse(this.res.text));
283+
const pauseLinks = job.getRelatedLinks('pauser');
284+
expect(pauseLinks.length).to.equal(0);
285+
const previewSkipLinks = job.getRelatedLinks('preview-skipper');
286+
expect(previewSkipLinks.length).to.equal(0);
287+
});
288+
289+
it('includes data size reduction information', function () {
290+
const job = JSON.parse(this.res.text);
291+
expect(job.originalDataSize).to.eql('3.07 MiB');
292+
expect(job.outputDataSize).to.eql('188.39 KiB');
293+
expect(job.dataSizePercentChange).to.eql('94.01% reduction');
294+
});
295+
208296
itIncludesADataExpirationField();
209297
});
210298

@@ -245,6 +333,13 @@ describe('Individual job status route', function () {
245333
expect(warnings[0].message).to.eql(warningMessage.message);
246334
});
247335

336+
it('includes data size reduction information', function () {
337+
const job = JSON.parse(this.res.text);
338+
expect(job.originalDataSize).to.eql('1.37 MiB');
339+
expect(job.outputDataSize).to.eql('92.53 KiB');
340+
expect(job.dataSizePercentChange).to.eql('93.41% reduction');
341+
});
342+
248343
itIncludesADataExpirationField();
249344
});
250345

@@ -968,3 +1063,44 @@ describe('Individual job status route', function () {
9681063
});
9691064
});
9701065
});
1066+
1067+
describe('unit tests for size reduction calcuation functions', function () {
1068+
// loop over some test cases for formatDataSize and sizeChangeMessage
1069+
const testCases = [
1070+
[1.3701868057250977, 0.09036064147949219, '1.37 MiB', '92.53 KiB', '93.41% reduction'],
1071+
[100.29018617250945, 10.0149421, '100.29 MiB', '10.01 MiB', '90.01% reduction'],
1072+
[1234567890, 9876543, '1.15 PiB', '9.42 TiB', '99.20% reduction'],
1073+
[10241024, 10024.5, '9.77 TiB', '9.79 GiB', '99.90% reduction'],
1074+
[1024, 10024, '1.00 GiB', '9.79 GiB', '878.91% increase'],
1075+
];
1076+
1077+
for (const testCase of testCases) {
1078+
const originalSize = +testCase[0];
1079+
const outputSize = +testCase[1];
1080+
it('formats MiB as a human readable string', function () {
1081+
expect(formatDataSize(originalSize)).to.eql(testCase[2]);
1082+
expect(formatDataSize(outputSize)).to.eql(testCase[3]);
1083+
});
1084+
it('provides a human-readable message about the size change of the data', function () {
1085+
expect(sizeChangeMessage({ originalSize, outputSize })).to.eql(testCase[4]);
1086+
});
1087+
}
1088+
1089+
describe('when the size reduction is very large', function () {
1090+
it('does not claim 100% size reduction', function () {
1091+
expect(sizeChangeMessage({ originalSize: 1234567890, outputSize: 1 })).to.eql('99.99% reduction');
1092+
});
1093+
});
1094+
1095+
describe('when the original size is zero', function () {
1096+
it('warns that the percent change does not apply', function () {
1097+
expect(sizeChangeMessage({ originalSize: 0, outputSize: 0 })).to.eql('Original size is 0 - percent size change N/A');
1098+
});
1099+
});
1100+
1101+
describe('when the output size is zero', function () {
1102+
it('warns that the percent change does not apply', function () {
1103+
expect(sizeChangeMessage({ originalSize: 100, outputSize: 0 })).to.eql('Output size is 0 - percent size change N/A');
1104+
});
1105+
});
1106+
});

0 commit comments

Comments
 (0)