Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[query] Allow lookback duration to be set in query parameters #1793

Merged
merged 7 commits into from
Jul 5, 2019
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
1 change: 1 addition & 0 deletions docs/query_engine/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Query using PromQL and returns JSON datapoints compatible with the Prometheus Gr
#### Optional

- `debug=[bool]`
- `lookback=[string|time duration]`: This sets the per request lookback duration to something other than the default set in config, can either be a time duration or the string "step" which sets the lookback to the same as the `step` request parameter.

### Header Params

Expand Down
8 changes: 3 additions & 5 deletions scripts/development/m3_stack/start_m3.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,9 @@ else
echo "Not running aggregator pipeline"
fi


echo "Sleeping to wait for nodes to initialize"
sleep 10

echo "Nodes online"
echo "Wait for coordinator API to be up"
ATTEMPTS=10 MAX_TIMEOUT=4 TIMEOUT=1 retry_with_backoff \
'curl -vvvsSf localhost:7201/health'

echo "Initializing namespaces"
curl -vvvsSf -X POST localhost:7201/api/v1/namespace -d '{
Expand Down
38 changes: 23 additions & 15 deletions scripts/docker-integration-tests/prometheus/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ function test_query_limits_applied {
}

function prometheus_query_native {
local endpoint="$1"
local query="$2"
local params="$3"
local metrics_type="$4"
local metrics_storage_policy="$5"
local jq_path="$6"
local expected_value="$7"
local endpoint=${endpoint:-}
local query=${query:-}
local params=${params:-}
local metrics_type=${metrics_type:-}
local metrics_storage_policy=${metrics_storage_policy:-}
local jq_path=${jq_path:-}
local expected_value=${expected_value:-}

params_prefixed=""
if [[ "$params" != "" ]]; then
Expand All @@ -156,19 +156,27 @@ function test_query_restrict_metrics_type {

# Test restricting to unaggregated metrics
echo "Test query restrict to unaggregated metrics type (instant)"
prometheus_query_native query "$METRIC_NAME_TEST_RESTRICT_WRITE" "$params_instant" \
"unaggregated" "" "$jq_path_instant" "42.42"
ATTEMPTS=50 TIMEOUT=2 MAX_TIMEOUT=4 \
endpoint=query query="$METRIC_NAME_TEST_RESTRICT_WRITE" params="$params_instant" \
metrics_type="unaggregated" jq_path="$jq_path_instant" expected_value="42.42" \
retry_with_backoff prometheus_query_native
echo "Test query restrict to unaggregated metrics type (range)"
prometheus_query_native query_range "$METRIC_NAME_TEST_RESTRICT_WRITE" "$params_range" \
"unaggregated" "" "$jq_path_range" "42.42"
ATTEMPTS=50 TIMEOUT=2 MAX_TIMEOUT=4 \
endpoint=query_range query="$METRIC_NAME_TEST_RESTRICT_WRITE" params="$params_range" \
metrics_type="unaggregated" jq_path="$jq_path_range" expected_value="42.42" \
retry_with_backoff prometheus_query_native

# Test restricting to aggregated metrics
echo "Test query restrict to aggregated metrics type (instant)"
prometheus_query_native query "$METRIC_NAME_TEST_RESTRICT_WRITE" "$params_instant" \
"aggregated" "15s:10h" "$jq_path_instant" "84.84"
ATTEMPTS=50 TIMEOUT=2 MAX_TIMEOUT=4 \
endpoint=query query="$METRIC_NAME_TEST_RESTRICT_WRITE" params="$params_instant" \
metrics_type="aggregated" metrics_storage_policy="15s:10h" jq_path="$jq_path_instant" expected_value="84.84" \
retry_with_backoff prometheus_query_native
echo "Test query restrict to aggregated metrics type (range)"
prometheus_query_native query_range "$METRIC_NAME_TEST_RESTRICT_WRITE" "$params_range" \
"aggregated" "15s:10h" "$jq_path_range" "84.84"
ATTEMPTS=50 TIMEOUT=2 MAX_TIMEOUT=4 \
endpoint=query_range query="$METRIC_NAME_TEST_RESTRICT_WRITE" params="$params_range" \
metrics_type="aggregated" metrics_storage_policy="15s:10h" jq_path="$jq_path_range" expected_value="84.84" \
retry_with_backoff prometheus_query_native
}

# Run all tests
Expand Down
109 changes: 109 additions & 0 deletions src/query/api/v1/handler/fetch_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,27 @@ package handler

import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"

"github.com/m3db/m3/src/metrics/policy"
"github.com/m3db/m3/src/query/errors"
"github.com/m3db/m3/src/query/storage"
xhttp "github.com/m3db/m3/src/x/net/http"
)

const (
// StepParam is the step parameter.
StepParam = "step"
// LookbackParam is the lookback parameter.
LookbackParam = "lookback"
maxInt64 = float64(math.MaxInt64)
minInt64 = float64(math.MinInt64)
)

// FetchOptionsBuilder builds fetch options based on a request and default
// config.
type FetchOptionsBuilder interface {
Expand Down Expand Up @@ -94,6 +107,24 @@ func (b fetchOptionsBuilder) NewFetchOptions(
return nil, xhttp.NewParseError(err, http.StatusBadRequest)
}
}

// NB(r): Eventually all query parameters that are common across the
// implementations should be parsed here so they can be fed to the engine.
if step, ok, err := ParseStep(req); err != nil {
err = fmt.Errorf(
"could not parse step: err=%v", err)
return nil, xhttp.NewParseError(err, http.StatusBadRequest)
} else if ok {
fetchOpts.Step = step
}
if lookback, ok, err := ParseLookbackDuration(req); err != nil {
err = fmt.Errorf(
"could not parse lookback: err=%v", err)
return nil, xhttp.NewParseError(err, http.StatusBadRequest)
} else if ok {
fetchOpts.LookbackDuration = &lookback
}

return fetchOpts, nil
}

Expand All @@ -105,3 +136,81 @@ func newOrExistingRestrictFetchOptions(
}
return &storage.RestrictFetchOptions{}
}

// ParseStep parses the step duration for an HTTP request.
func ParseStep(r *http.Request) (time.Duration, bool, error) {
step := r.FormValue(StepParam)
if step == "" {
return 0, false, nil
}
value, err := parseStep(r, StepParam)
if err != nil {
return 0, false, err
}
return value, true, err
}

func parseStep(r *http.Request, key string) (time.Duration, error) {
step, err := ParseDuration(r, key)
if err != nil {
return 0, err
}
if step <= 0 {
return 0, fmt.Errorf("expected postive step size, instead got: %d", step)
}
return step, nil
}

// ParseLookbackDuration parses a lookback duration for an HTTP request.
func ParseLookbackDuration(r *http.Request) (time.Duration, bool, error) {
lookback := r.FormValue(LookbackParam)
if lookback == "" {
return 0, false, nil
}

if lookback == StepParam {
// Use the step size as lookback.
step, err := parseStep(r, StepParam)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe should explicitly add a sanity check for step > 0? It should fail already on ParseStep but sanity check here may be useful

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yup so I added that inside parseStep

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oops, I'm blind

if err != nil {
return 0, false, err
}
return step, true, nil
}

// Otherwise it is specified as duration value.
value, err := parseStep(r, LookbackParam)
if err != nil {
return 0, false, err
}

return value, true, nil
}

// ParseDuration parses a duration HTTP parameter.
// nolint: unparam
func ParseDuration(r *http.Request, key string) (time.Duration, error) {
str := strings.TrimSpace(r.FormValue(key))
if str == "" {
return 0, errors.ErrNotFound
}

value, durationErr := time.ParseDuration(str)
if durationErr == nil {
return value, nil
}

// Try parsing as a float value specifying seconds, the Prometheus default.
seconds, floatErr := strconv.ParseFloat(str, 64)
if floatErr == nil {
ts := seconds * float64(time.Second)
if ts > maxInt64 || ts < minInt64 {
Copy link
Collaborator

@arnikola arnikola Jul 5, 2019

Choose a reason for hiding this comment

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

Should the lower bound just check ts <= 0? Negative seems invalid for duration parsing

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was existing code, I could update though.

return 0, fmt.Errorf("cannot parse duration='%s': as_float_err="+
"int64 overflow after float conversion", str)
}

return time.Duration(ts), nil
}

return 0, fmt.Errorf("cannot parse duration='%s': as_duration_err=%s, as_float_err=%s",
str, durationErr, floatErr)
}
138 changes: 137 additions & 1 deletion src/query/api/v1/handler/fetch_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,34 @@
package handler

import (
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/m3db/m3/src/metrics/policy"
"github.com/m3db/m3/src/query/storage"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFetchOptionsBuilder(t *testing.T) {
type expectedLookback struct {
value time.Duration
}

tests := []struct {
name string
defaultLimit int
headers map[string]string
query string
expectedLimit int
expectedRestrict *storage.RestrictFetchOptions
expectedLookback *expectedLookback
expectedErr bool
}{
{
Expand Down Expand Up @@ -103,6 +115,35 @@ func TestFetchOptionsBuilder(t *testing.T) {
},
expectedErr: true,
},
{
name: "can set lookback duration",
query: "lookback=10s",
expectedLookback: &expectedLookback{
value: 10 * time.Second,
},
},
{
name: "can set lookback duration based on step",
query: "lookback=step&step=10s",
Copy link
Collaborator

Choose a reason for hiding this comment

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

May be worth adding in lookback=step, lookback=step&step=invalid , lookback=step&step=-1 cases here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure thing.

expectedLookback: &expectedLookback{
value: 10 * time.Second,
},
},
{
name: "bad lookback returns error",
query: "lookback=foo",
expectedErr: true,
},
{
name: "loookback step but step is bad",
query: "lookback=step&step=invalid",
expectedErr: true,
},
{
name: "lookback step but step is negative",
query: "lookback=step&step=-1",
expectedErr: true,
},
}

for _, test := range tests {
Expand All @@ -111,7 +152,11 @@ func TestFetchOptionsBuilder(t *testing.T) {
Limit: test.defaultLimit,
})

req := httptest.NewRequest("GET", "/foo", nil)
url := "/foo"
if test.query != "" {
url += "?" + test.query
}
req := httptest.NewRequest("GET", url, nil)
for k, v := range test.headers {
req.Header.Add(k, v)
}
Expand All @@ -127,9 +172,100 @@ func TestFetchOptionsBuilder(t *testing.T) {
require.NotNil(t, opts.RestrictFetchOptions)
require.Equal(t, *test.expectedRestrict, *opts.RestrictFetchOptions)
}
if test.expectedLookback == nil {
require.Nil(t, opts.LookbackDuration)
} else {
require.NotNil(t, opts.LookbackDuration)
require.Equal(t, test.expectedLookback.value, *opts.LookbackDuration)
}
} else {
require.Error(t, err)
}
})
}
}

func TestInvalidStep(t *testing.T) {
req := httptest.NewRequest("GET", "/foo", nil)
vals := make(url.Values)
vals.Del(StepParam)
vals.Add(StepParam, "-10.50s")
req.URL.RawQuery = vals.Encode()
_, _, err := ParseStep(req)
require.NotNil(t, err, "unable to parse request")
}

func TestParseLookbackDuration(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/foo", nil)
_, ok, err := ParseLookbackDuration(r)
require.NoError(t, err)
require.False(t, ok)

r = httptest.NewRequest(http.MethodGet, "/foo?step=60s&lookback=step", nil)
v, ok, err := ParseLookbackDuration(r)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, time.Minute, v)

r = httptest.NewRequest(http.MethodGet, "/foo?step=60s&lookback=120s", nil)
v, ok, err = ParseLookbackDuration(r)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, 2*time.Minute, v)

r = httptest.NewRequest(http.MethodGet, "/foo?step=60s&lookback=foobar", nil)
_, _, err = ParseLookbackDuration(r)
require.Error(t, err)
}

func TestParseDuration(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/foo?step=10s", nil)
require.NoError(t, err)
v, err := ParseDuration(r, StepParam)
require.NoError(t, err)
assert.Equal(t, 10*time.Second, v)
}

func TestParseFloatDuration(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/foo?step=10.50m", nil)
require.NoError(t, err)
v, err := ParseDuration(r, StepParam)
require.NoError(t, err)
assert.Equal(t, 10*time.Minute+30*time.Second, v)

r = httptest.NewRequest(http.MethodGet, "/foo?step=10.00m", nil)
require.NoError(t, err)
v, err = ParseDuration(r, StepParam)
require.NoError(t, err)
assert.Equal(t, 10*time.Minute, v)
}

func TestParseDurationParsesIntAsSeconds(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/foo?step=30", nil)
require.NoError(t, err)
v, err := ParseDuration(r, StepParam)
require.NoError(t, err)
assert.Equal(t, 30*time.Second, v)
}

func TestParseDurationParsesFloatAsSeconds(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/foo?step=30.00", nil)
require.NoError(t, err)
v, err := ParseDuration(r, StepParam)
require.NoError(t, err)
assert.Equal(t, 30*time.Second, v)
}

func TestParseDurationError(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/foo?step=bar10", nil)
require.NoError(t, err)
_, err = ParseDuration(r, StepParam)
assert.Error(t, err)
}

func TestParseDurationOverflowError(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/foo?step=%f", float64(math.MaxInt64)), nil)
require.NoError(t, err)
_, err = ParseDuration(r, StepParam)
assert.Error(t, err)
}
Loading