Skip to content

Commit b8af038

Browse files
committed
[ML] Fixes anomaly chart and validation for one week bucket span
1 parent cc4c172 commit b8af038

File tree

11 files changed

+82
-29
lines changed

11 files changed

+82
-29
lines changed

x-pack/plugins/ml/common/util/job_utils.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import _ from 'lodash';
88
import semver from 'semver';
9+
import { Duration } from 'moment';
910
// @ts-ignore
1011
import numeral from '@elastic/numeral';
1112

@@ -433,7 +434,7 @@ export function basicJobValidation(
433434
messages.push({ id: 'bucket_span_empty' });
434435
valid = false;
435436
} else {
436-
if (isValidTimeFormat(job.analysis_config.bucket_span)) {
437+
if (isValidTimeInterval(job.analysis_config.bucket_span)) {
437438
messages.push({
438439
id: 'bucket_span_valid',
439440
bucketSpan: job.analysis_config.bucket_span,
@@ -490,14 +491,14 @@ export function basicDatafeedValidation(datafeed: Datafeed): ValidationResults {
490491

491492
if (datafeed) {
492493
let queryDelayMessage = { id: 'query_delay_valid' };
493-
if (isValidTimeFormat(datafeed.query_delay) === false) {
494+
if (isValidTimeInterval(datafeed.query_delay) === false) {
494495
queryDelayMessage = { id: 'query_delay_invalid' };
495496
valid = false;
496497
}
497498
messages.push(queryDelayMessage);
498499

499500
let frequencyMessage = { id: 'frequency_valid' };
500-
if (isValidTimeFormat(datafeed.frequency) === false) {
501+
if (isValidTimeInterval(datafeed.frequency) === false) {
501502
frequencyMessage = { id: 'frequency_invalid' };
502503
valid = false;
503504
}
@@ -591,12 +592,33 @@ export function validateGroupNames(job: Job): ValidationResults {
591592
};
592593
}
593594

594-
function isValidTimeFormat(value: string | undefined): boolean {
595+
/**
596+
* Parses the supplied string to a time interval suitable for use in an ML anomaly
597+
* detection job or datafeed.
598+
* @param value the string to parse
599+
* @return {Duration} the parsed interval, or null if it does not represent a valid
600+
* time interval.
601+
*/
602+
export function parseTimeIntervalForJob(value: string | undefined): Duration | null {
603+
if (value === undefined) {
604+
return null;
605+
}
606+
607+
// Must be a valid interval, greater than zero,
608+
// and if specified in ms must be a multiple of 1000ms.
609+
const interval = parseInterval(value, true);
610+
return interval !== null && interval.asMilliseconds() !== 0 && interval.milliseconds() === 0
611+
? interval
612+
: null;
613+
}
614+
615+
// Checks that the value for a field which represents a time interval,
616+
// such as a job bucket span or datafeed query delay, is valid.
617+
function isValidTimeInterval(value: string | undefined): boolean {
595618
if (value === undefined) {
596619
return true;
597620
}
598-
const interval = parseInterval(value);
599-
return interval !== null && interval.asMilliseconds() !== 0;
621+
return parseTimeIntervalForJob(value) !== null;
600622
}
601623

602624
// Returns the latest of the last source data and last processed bucket timestamp,

x-pack/plugins/ml/common/util/parse_interval.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { parseInterval } from './parse_interval';
88

99
describe('ML parse interval util', () => {
10-
test('correctly parses an interval containing unit and value', () => {
10+
test('should correctly parse an interval containing a valid unit and value', () => {
1111
expect(parseInterval('1d')!.as('d')).toBe(1);
1212
expect(parseInterval('2y')!.as('y')).toBe(2);
1313
expect(parseInterval('5M')!.as('M')).toBe(5);
@@ -20,15 +20,25 @@ describe('ML parse interval util', () => {
2020
expect(parseInterval('0s')!.as('h')).toBe(0);
2121
});
2222

23-
test('correctly handles zero value intervals', () => {
23+
test('should correctly handle zero value intervals', () => {
2424
expect(parseInterval('0h')!.as('h')).toBe(0);
2525
expect(parseInterval('0d')).toBe(null);
2626
});
2727

28-
test('returns null for an invalid interval', () => {
28+
test('should return null for an invalid interval', () => {
2929
expect(parseInterval('')).toBe(null);
3030
expect(parseInterval('234asdf')).toBe(null);
3131
expect(parseInterval('m')).toBe(null);
3232
expect(parseInterval('1.5h')).toBe(null);
3333
});
34+
35+
test('should correctly check for whether the interval units are valid Elasticsearch time units', () => {
36+
expect(parseInterval('100s', true)!.as('s')).toBe(100);
37+
expect(parseInterval('5m', true)!.as('m')).toBe(5);
38+
expect(parseInterval('24h', true)!.as('h')).toBe(24);
39+
expect(parseInterval('7d', true)!.as('d')).toBe(7);
40+
expect(parseInterval('1w', true)).toBe(null);
41+
expect(parseInterval('1M', true)).toBe(null);
42+
expect(parseInterval('1y', true)).toBe(null);
43+
});
3444
});

x-pack/plugins/ml/common/util/parse_interval.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ const INTERVAL_STRING_RE = new RegExp('^([0-9]*)\\s*(' + dateMath.units.join('|'
1616
// for units of hour or less.
1717
const SUPPORT_ZERO_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h'];
1818

19+
// List of time units which are supported for use in Elasticsearch durations
20+
// (such as anomaly detection job bucket spans)
21+
// See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units
22+
const SUPPORT_ES_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h', 'd'];
23+
1924
// Parses an interval String, such as 7d, 1h or 30m to a moment duration.
25+
// Optionally carries out an additional check that the interval is supported as a
26+
// time unit by Elasticsearch, as units greater than 'd' for example cannot be used
27+
// for anomaly detection job bucket spans.
2028
// Differs from the Kibana ui/utils/parse_interval in the following ways:
2129
// 1. A value-less interval such as 'm' is not allowed - in line with the ML back-end
2230
// not accepting such interval Strings for the bucket span of a job.
@@ -25,7 +33,7 @@ const SUPPORT_ZERO_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h'];
2533
// to work with units less than 'day'.
2634
// 3. Fractional intervals e.g. 1.5h or 4.5d are not allowed, in line with the behaviour
2735
// of the Elasticsearch date histogram aggregation.
28-
export function parseInterval(interval: string): Duration | null {
36+
export function parseInterval(interval: string, checkValidEsUnit = false): Duration | null {
2937
const matches = String(interval).trim().match(INTERVAL_STRING_RE);
3038
if (!Array.isArray(matches) || matches.length < 3) {
3139
return null;
@@ -36,8 +44,13 @@ export function parseInterval(interval: string): Duration | null {
3644
const unit = matches[2] as SupportedUnits;
3745

3846
// In line with moment.js, only allow zero value intervals when the unit is less than 'day'.
39-
// And check for isNaN as e.g. valueless 'm' will pass the regex test.
40-
if (isNaN(value) || (value < 1 && SUPPORT_ZERO_DURATION_UNITS.indexOf(unit) === -1)) {
47+
// And check for isNaN as e.g. valueless 'm' will pass the regex test,
48+
// plus an optional check that the unit is not w/M/y which are not fully supported by ES.
49+
if (
50+
isNaN(value) ||
51+
(value < 1 && SUPPORT_ZERO_DURATION_UNITS.indexOf(unit) === -1) ||
52+
(checkValidEsUnit === true && SUPPORT_ES_DURATION_UNITS.indexOf(unit) === -1)
53+
) {
4154
return null;
4255
}
4356

x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export class JobCreator {
155155
}
156156

157157
protected _setBucketSpanMs(bucketSpan: BucketSpan) {
158-
const bs = parseInterval(bucketSpan);
158+
const bs = parseInterval(bucketSpan, true);
159159
this._bucketSpanMs = bs === null ? 0 : bs.asMilliseconds();
160160
}
161161

x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/single_metric_job_creator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export class SingleMetricJobCreator extends JobCreator {
7676
const functionName = this._aggs[0].dslName;
7777
const timeField = this._job_config.data_description.time_field;
7878

79-
const duration = parseInterval(this._job_config.analysis_config.bucket_span);
79+
const duration = parseInterval(this._job_config.analysis_config.bucket_span, true);
8080
if (duration === null) {
8181
return;
8282
}

x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function populateValidationMessages(
142142
basicValidations.bucketSpan.message = msg;
143143
} else if (validationResults.contains('bucket_span_invalid')) {
144144
basicValidations.bucketSpan.valid = false;
145-
basicValidations.bucketSpan.message = invalidTimeFormatMessage(
145+
basicValidations.bucketSpan.message = invalidTimeIntervalMessage(
146146
jobConfig.analysis_config.bucket_span
147147
);
148148
}
@@ -163,12 +163,12 @@ export function populateValidationMessages(
163163

164164
if (validationResults.contains('query_delay_invalid')) {
165165
basicValidations.queryDelay.valid = false;
166-
basicValidations.queryDelay.message = invalidTimeFormatMessage(datafeedConfig.query_delay);
166+
basicValidations.queryDelay.message = invalidTimeIntervalMessage(datafeedConfig.query_delay);
167167
}
168168

169169
if (validationResults.contains('frequency_invalid')) {
170170
basicValidations.frequency.valid = false;
171-
basicValidations.frequency.message = invalidTimeFormatMessage(datafeedConfig.frequency);
171+
basicValidations.frequency.message = invalidTimeIntervalMessage(datafeedConfig.frequency);
172172
}
173173
}
174174

@@ -202,16 +202,18 @@ export function checkForExistingJobAndGroupIds(
202202
};
203203
}
204204

205-
function invalidTimeFormatMessage(value: string | undefined) {
205+
function invalidTimeIntervalMessage(value: string | undefined) {
206206
return i18n.translate(
207207
'xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage',
208208
{
209209
defaultMessage:
210-
'{value} is not a valid time interval format e.g. {tenMinutes}, {oneHour}. It also needs to be higher than zero.',
210+
'{value} is not a valid time interval format e.g. {thirtySeconds}, {tenMinutes}, {oneHour}, {sevenDays}. It also needs to be higher than zero.',
211211
values: {
212212
value,
213+
thirtySeconds: '30s',
213214
tenMinutes: '10m',
214215
oneHour: '1h',
216+
sevenDays: '7d',
215217
},
216218
}
217219
);

x-pack/plugins/ml/public/application/util/time_buckets.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import { getFieldFormats, getUiSettings } from './dependency_cache';
1414
import { FIELD_FORMAT_IDS, UI_SETTINGS } from '../../../../../../src/plugins/data/public';
1515

1616
const unitsDesc = dateMath.unitsDesc;
17-
const largeMax = unitsDesc.indexOf('w'); // Multiple units of week or longer converted to days for ES intervals.
17+
18+
// Index of the list of time interval units at which larger units (i.e. weeks, months, years) need
19+
// need to be converted to multiples of the largest unit supported in ES aggregation intervals (i.e. days).
20+
// Note that similarly the largest interval supported for ML bucket spans is 'd'.
21+
const timeUnitsMaxSupportedIndex = unitsDesc.indexOf('w');
1822

1923
const calcAuto = timeBucketsCalcAutoIntervalProvider();
2024

@@ -383,9 +387,11 @@ export function calcEsInterval(duration) {
383387
const val = duration.as(unit);
384388
// find a unit that rounds neatly
385389
if (val >= 1 && Math.floor(val) === val) {
386-
// if the unit is "large", like years, but isn't set to 1, ES will throw an error.
390+
// Apart from for date histograms, ES only supports time units up to 'd',
391+
// meaning we can't for example use 'w' for job bucket spans.
392+
// See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units
387393
// So keep going until we get out of the "large" units.
388-
if (i <= largeMax && val !== 1) {
394+
if (i <= timeUnitsMaxSupportedIndex) {
389395
continue;
390396
}
391397

x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
*/
66

77
import { estimateBucketSpanFactory } from '../../models/bucket_span_estimator';
8-
import { mlFunctionToESAggregation } from '../../../common/util/job_utils';
8+
import { mlFunctionToESAggregation, parseTimeIntervalForJob } from '../../../common/util/job_utils';
99
import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../common/constants/validation';
10-
import { parseInterval } from '../../../common/util/parse_interval';
1110

1211
import { validateJobObject } from './validate_job_object';
1312

@@ -65,8 +64,11 @@ export async function validateBucketSpan(
6564
}
6665

6766
const messages = [];
68-
const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span);
69-
if (parsedBucketSpan === null || parsedBucketSpan.asMilliseconds() === 0) {
67+
68+
// Bucket span must be a valid interval, greater than 0,
69+
// and if specified in ms must be a multiple of 1000ms
70+
const parsedBucketSpan = parseTimeIntervalForJob(job.analysis_config.bucket_span);
71+
if (parsedBucketSpan === null) {
7072
messages.push({ id: 'bucket_span_invalid' });
7173
return messages;
7274
}

x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export async function validateTimeRange(
7878
}
7979

8080
// check for minimum time range (25 buckets or 2 hours, whichever is longer)
81-
const interval = parseInterval(job.analysis_config.bucket_span);
81+
const interval = parseInterval(job.analysis_config.bucket_span, true);
8282
if (interval === null) {
8383
messages.push({ id: 'bucket_span_invalid' });
8484
} else {

x-pack/plugins/translations/translations/ja-JP.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10709,7 +10709,6 @@
1070910709
"xpack.ml.newJob.wizard.timeRangeStep.timeRangePicker.startDateLabel": "開始日",
1071010710
"xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage": "バケットスパンを設定する必要があります",
1071110711
"xpack.ml.newJob.wizard.validateJob.duplicatedDetectorsErrorMessage": "重複する検知器が検出されました。",
10712-
"xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage": "{value} は有効な時間間隔のフォーマット (例: {tenMinutes}、{oneHour}) ではありません。また、0 よりも大きい数字である必要があります。",
1071310712
"xpack.ml.newJob.wizard.validateJob.groupNameAlreadyExists": "グループ ID が既に存在します。グループ ID は既存のジョブやグループと同じにできません。",
1071410713
"xpack.ml.newJob.wizard.validateJob.jobGroupAllowedCharactersDescription": "ジョブグループ名にはアルファベットの小文字 (a-z と 0-9)、ハイフンまたはアンダーラインが使用でき、最初と最後を英数字にする必要があります",
1071510714
"xpack.ml.newJob.wizard.validateJob.jobGroupMaxLengthDescription": "ジョブグループ名は {maxLength, plural, one {# 文字} other {# 文字}} 以内でなければなりません。",

0 commit comments

Comments
 (0)