Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions docs/docs/reference/project-files/metrics-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ _[integer]_ - Refers to the first day of the week for time grain aggregation (fo

_[integer]_ - Refers to the first month of the year for time grain aggregation. The valid values are 1 through 12 where January=1 and December=12

### `max_query_time_range`

_[string]_ - The maximum time span any single query against this metrics view may cover, expressed as an ISO 8601 duration with day-or-larger granularity (e.g. `P90D`, `P3M`, `P1Y`). Sub-day durations such as `PT12H` are not supported. Applies independently to the primary and comparison time ranges. If unset, no limit is enforced.

### `dimensions`

_[array of object]_ - Relates to exploring segments or dimensions of your data and filtering the dashboard
Expand Down
2,226 changes: 1,127 additions & 1,099 deletions proto/gen/rill/runtime/v1/queries.pb.go

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions proto/gen/rill/runtime/v1/queries.pb.validate.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2,314 changes: 1,164 additions & 1,150 deletions proto/gen/rill/runtime/v1/resources.pb.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions proto/gen/rill/runtime/v1/resources.pb.validate.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions proto/gen/rill/runtime/v1/runtime.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6500,6 +6500,12 @@ definitions:
items:
type: object
$ref: '#/definitions/MetricsViewSpecRollup'
maxQueryTimeRange:
type: string
description: |-
Maximum time span any single query against this metrics view may cover, as an ISO 8601 duration with day-or-larger granularity (e.g. "P90D", "P3M", "P1Y").
Sub-day durations (hours, minutes, seconds) are not supported. Applies to queries that take a time range, including the comparison time range.
Time-range introspection RPCs are exempt. If unset, no limit is enforced.
v1MetricsViewSpecAnnotation:
type: object
properties:
Expand Down Expand Up @@ -6562,6 +6568,12 @@ definitions:
timeRangeSummary:
$ref: '#/definitions/v1TimeRangeSummary'
title: Not optional, not null
maxQueryTimeRangeMillis:
type: string
format: int64
description: |-
The metrics view's max_query_time_range property resolved into milliseconds against the current time.
Zero if the metrics view does not configure max_query_time_range.
trace:
$ref: '#/definitions/v1Trace'
description: Traces of spans captured during request execution. Only populated if trace was set to true in the request.
Expand All @@ -6585,6 +6597,12 @@ definitions:
description: |-
The same values as resolved_time_ranges for backwards compatibility.
Deprecated: use resolved_time_ranges instead.
maxQueryTimeRangeMillis:
type: string
format: int64
description: |-
The metrics view's max_query_time_range property resolved into milliseconds against the request's reference time.
Zero if the metrics view does not configure max_query_time_range.
trace:
$ref: '#/definitions/v1Trace'
description: Traces of spans captured during request execution. Only populated if trace was set to true in the request.
Expand Down
6 changes: 6 additions & 0 deletions proto/rill/runtime/v1/queries.proto
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,9 @@ message MetricsViewTimeRangeRequest {
message MetricsViewTimeRangeResponse {
// Not optional, not null
TimeRangeSummary time_range_summary = 1;
// The metrics view's max_query_time_range property resolved into milliseconds against the current time.
// Zero if the metrics view does not configure max_query_time_range.
int64 max_query_time_range_millis = 3;
// Traces of spans captured during request execution. Only populated if trace was set to true in the request.
Trace trace = 2;
}
Expand Down Expand Up @@ -977,6 +980,9 @@ message MetricsViewTimeRangesResponse {
// The same values as resolved_time_ranges for backwards compatibility.
// Deprecated: use resolved_time_ranges instead.
repeated TimeRange time_ranges = 2;
// The metrics view's max_query_time_range property resolved into milliseconds against the request's reference time.
// Zero if the metrics view does not configure max_query_time_range.
int64 max_query_time_range_millis = 5;
// Traces of spans captured during request execution. Only populated if trace was set to true in the request.
Trace trace = 4;
}
Expand Down
4 changes: 4 additions & 0 deletions proto/rill/runtime/v1/resources.proto
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ message MetricsViewSpec {
// Keys and values are stored as templates and will be resolved at query time.
map<string, string> query_attributes = 33;
repeated Rollup rollups = 34;
// Maximum time span any single query against this metrics view may cover, as an ISO 8601 duration with day-or-larger granularity (e.g. "P90D", "P3M", "P1Y").
// Sub-day durations (hours, minutes, seconds) are not supported. Applies to queries that take a time range, including the comparison time range.
// Time-range introspection RPCs are exempt. If unset, no limit is enforced.
string max_query_time_range = 36;
}

message SecurityRule {
Expand Down
40 changes: 30 additions & 10 deletions runtime/metricsview/executor/executor_enforce_query_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,49 @@ package executor

import (
"fmt"
"time"

"github.com/rilldata/rill/runtime/metricsview"
)

// enforceQueryLimits checks that the query adheres to any limits specified in the QueryLimits. This should be called after time_range is resolved.
// enforceQueryLimits checks that the query adheres to any limits specified in the QueryLimits or on the metrics view spec.
// This should be called after time_range is resolved.
func (e *Executor) enforceQueryLimits(qry *metricsview.Query) error {
if qry.QueryLimits == nil {
return nil
if qry.QueryLimits != nil && qry.QueryLimits.RequireTimeRange && (qry.TimeRange == nil || qry.TimeRange.IsZero()) {
return fmt.Errorf("a valid time_range should be specified for the query")
}

if qry.QueryLimits.RequireTimeRange && (qry.TimeRange == nil || qry.TimeRange.IsZero()) {
return fmt.Errorf("a valid time_range should be specified for the query")
if err := e.enforceMaxTimeRange(qry, qry.TimeRange); err != nil {
return err
}
return e.enforceMaxTimeRange(qry, qry.ComparisonTimeRange)
}

// if require_time_range not set and time range is not specified, we skip the max time range check
if qry.QueryLimits.MaxTimeRangeDays <= 0 || qry.TimeRange == nil || qry.TimeRange.IsZero() {
// enforceMaxTimeRange returns nil if tr fits within the configured cap, else an error.
// A caller-provided QueryLimits.MaxTimeRangeDays takes precedence over the metrics view's max_query_time_range,
// so the AI path's rill.ai.max_time_range_days env var can tighten (but not loosen) the spec value.
func (e *Executor) enforceMaxTimeRange(qry *metricsview.Query, tr *metricsview.TimeRange) error {
if tr == nil || tr.IsZero() {
return nil
}

days := qry.TimeRange.End.Sub(qry.TimeRange.Start).Hours() / 24
if days > float64(qry.QueryLimits.MaxTimeRangeDays) {
return fmt.Errorf("time range for query cannot exceed %d days, this can be adjusted using rill.ai.max_time_range_days env var", qry.QueryLimits.MaxTimeRangeDays)
if qry.QueryLimits != nil && qry.QueryLimits.MaxTimeRangeDays > 0 {
maxDur := time.Duration(qry.QueryLimits.MaxTimeRangeDays) * 24 * time.Hour
if tr.End.Sub(tr.Start) > maxDur {
return fmt.Errorf("time range for query cannot exceed %d days, configured via the rill.ai.max_time_range_days env var", qry.QueryLimits.MaxTimeRangeDays)
}
return nil
Comment thread
AdityaHegde marked this conversation as resolved.
Outdated
}

if e.metricsView == nil {
return nil
}
maxDur := metricsview.ResolveMaxQueryTimeRange(e.metricsView.MaxQueryTimeRange, time.Now())
if maxDur <= 0 {
return nil
}
if tr.End.Sub(tr.Start) > maxDur {
return fmt.Errorf("time range for query cannot exceed %s, configured via the metrics view's max_query_time_range property", e.metricsView.MaxQueryTimeRange)
}
return nil
}
100 changes: 100 additions & 0 deletions runtime/metricsview/executor/executor_enforce_query_limits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package executor

import (
"testing"
"time"

runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1"
"github.com/rilldata/rill/runtime/metricsview"
"github.com/stretchr/testify/require"
)

func TestEnforceQueryLimits(t *testing.T) {
tr := func(days int) *metricsview.TimeRange {
end := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
return &metricsview.TimeRange{
Start: end.AddDate(0, 0, -days),
End: end,
}
}

tests := []struct {
name string
spec string
callerCap int64
query *metricsview.Query
wantErr string
}{
{
name: "no cap",
query: &metricsview.Query{TimeRange: tr(365)},
},
{
name: "spec cap, range under",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(7)},
},
{
name: "spec cap, range over",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(60)},
wantErr: "max_query_time_range",
},
{
name: "caller cap wins when tighter than spec",
spec: "P90D",
callerCap: 30,
query: &metricsview.Query{TimeRange: tr(60)},
wantErr: "rill.ai.max_time_range_days",
},
{
name: "caller cap, range under",
callerCap: 30,
query: &metricsview.Query{TimeRange: tr(7)},
},
{
name: "comparison range over cap",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(7), ComparisonTimeRange: tr(60)},
wantErr: "max_query_time_range",
},
{
name: "primary range over cap, comparison fits",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(60), ComparisonTimeRange: tr(7)},
wantErr: "max_query_time_range",
},
{
name: "spec set, no time range on query",
spec: "P30D",
query: &metricsview.Query{},
},
{
name: "require_time_range without time range",
query: &metricsview.Query{QueryLimits: &metricsview.QueryLimits{
RequireTimeRange: true,
}},
wantErr: "valid time_range",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &Executor{metricsView: &runtimev1.MetricsViewSpec{MaxQueryTimeRange: tt.spec}}
q := tt.query
if tt.callerCap > 0 {
if q.QueryLimits == nil {
q.QueryLimits = &metricsview.QueryLimits{}
}
q.QueryLimits.MaxTimeRangeDays = tt.callerCap
}
err := e.enforceQueryLimits(q)
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
26 changes: 26 additions & 0 deletions runtime/metricsview/max_query_time_range.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package metricsview

import (
"time"

"github.com/rilldata/rill/runtime/pkg/rilltime"
)

// ResolveMaxQueryTimeRange resolves a metrics view's max_query_time_range property to a duration relative to now.
// Returns 0 for empty or unparseable input.
func ResolveMaxQueryTimeRange(maxQueryTimeRange string, now time.Time) time.Duration {
if maxQueryTimeRange == "" {
return 0
}
expr, err := rilltime.Parse(maxQueryTimeRange, rilltime.ParseOptions{})
if err != nil {
return 0
}
start, end, _ := expr.Eval(rilltime.EvalOptions{
Now: now,
MinTime: now,
MaxTime: now,
Watermark: now,
})
return end.Sub(start)
}
17 changes: 17 additions & 0 deletions runtime/parser/parse_metrics_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1"
"github.com/rilldata/rill/runtime/pkg/duration"
"github.com/rilldata/rill/runtime/pkg/rilltime"
"golang.org/x/exp/maps"
"google.golang.org/protobuf/types/known/structpb"
Expand All @@ -34,6 +35,7 @@ type MetricsViewYAML struct {
SmallestTimeGrain string `yaml:"smallest_time_grain"`
FirstDayOfWeek uint32 `yaml:"first_day_of_week"`
FirstMonthOfYear uint32 `yaml:"first_month_of_year"`
MaxQueryTimeRange string `yaml:"max_query_time_range"`
Dimensions []*struct {
Name string
DisplayName string `yaml:"display_name"`
Expand Down Expand Up @@ -644,6 +646,20 @@ func (p *Parser) parseMetricsView(node *Node) error {
return fmt.Errorf("invalid first month of year %d, must be between 1 and 12", tmp.FirstMonthOfYear)
}

if tmp.MaxQueryTimeRange != "" {
if strings.HasPrefix(tmp.MaxQueryTimeRange, "rill-") {
return fmt.Errorf(`invalid "max_query_time_range" %q: only fixed ISO 8601 day-or-larger durations are allowed`, tmp.MaxQueryTimeRange)
}
d, err := duration.ParseISO8601(tmp.MaxQueryTimeRange)
if err != nil {
return fmt.Errorf(`invalid "max_query_time_range": %w`, err)
}
sd, ok := d.(duration.StandardDuration)
if !ok || sd.Hour != 0 || sd.Minute != 0 || sd.Second != 0 {
return fmt.Errorf(`invalid "max_query_time_range" %q: sub-day granularity is not supported, use a duration like P1D, P30D, P3M, or P1Y`, tmp.MaxQueryTimeRange)
}
}

if tmp.Cache.TimestampsTTL != "" {
if _, err := time.ParseDuration(tmp.Cache.TimestampsTTL); err != nil {
return fmt.Errorf(`invalid "cache.timestamps_ttl": %w`, err)
Expand Down Expand Up @@ -867,6 +883,7 @@ func (p *Parser) parseMetricsView(node *Node) error {
spec.SmallestTimeGrain = smallestTimeGrain
spec.FirstDayOfWeek = tmp.FirstDayOfWeek
spec.FirstMonthOfYear = tmp.FirstMonthOfYear
spec.MaxQueryTimeRange = tmp.MaxQueryTimeRange
if tmp.Cache.TimestampsTTL != "" {
d, _ := time.ParseDuration(tmp.Cache.TimestampsTTL) // already validated above
spec.CacheTimestampsTtlSeconds = int64(d.Seconds())
Expand Down
Loading
Loading