Skip to content
Open
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
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does max_query_time_range not apply when rill.ai.max_time_range_days is set?

}

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