Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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,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.

6 changes: 6 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
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
61 changes: 51 additions & 10 deletions runtime/metricsview/executor/executor_enforce_query_limits.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,71 @@
package executor

import (
"errors"
"fmt"
"time"

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

// 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 qry.TimeRange == nil || qry.TimeRange.IsZero() {
return nil
}

// 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() {
maxRange, errMsg := e.maxTimeRange(qry)
if maxRange <= 0 {
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 err := checkTimeRangeWithinCap(qry.TimeRange, maxRange, errMsg); err != nil {
return err
}
if qry.ComparisonTimeRange != nil && !qry.ComparisonTimeRange.IsZero() {
if err := checkTimeRangeWithinCap(qry.ComparisonTimeRange, maxRange, errMsg); err != nil {
return err
}
}
return nil
}

// maxTimeRange returns the effective cap on the query's time range and a pre-formatted error message
// describing where the cap was configured. Returns 0 if no cap applies.
//
// An explicit caller-provided QueryLimits.MaxTimeRangeDays takes precedence over the metrics view's
// max_query_time_range spec property — this matters for the AI tool path which tightens the cap via
// the rill.ai.max_time_range_days env var.
func (e *Executor) maxTimeRange(qry *metricsview.Query) (time.Duration, string) {
Comment thread
AdityaHegde marked this conversation as resolved.
Outdated
if qry.QueryLimits != nil && qry.QueryLimits.MaxTimeRangeDays > 0 {
days := qry.QueryLimits.MaxTimeRangeDays
return time.Duration(days) * 24 * time.Hour,
fmt.Sprintf("time range for query cannot exceed %d days, configured via the rill.ai.max_time_range_days env var", days)
}
if e.metricsView != nil && e.metricsView.MaxQueryTimeRange != "" {
d, err := duration.ParseISO8601(e.metricsView.MaxQueryTimeRange)
Comment thread
AdityaHegde marked this conversation as resolved.
Outdated
if err != nil {
return 0, ""
}
native, ok := d.EstimateNative()
if !ok || native <= 0 {
return 0, ""
}
return native,
fmt.Sprintf("time range for query cannot exceed %s, configured via the metrics view's max_query_time_range property", e.metricsView.MaxQueryTimeRange)
}
return 0, ""
}

func checkTimeRangeWithinCap(tr *metricsview.TimeRange, maxRange time.Duration, errMsg string) error {
if tr.End.Sub(tr.Start) > maxRange {
return errors.New(errMsg)
}
return nil
}
113 changes: 113 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,113 @@
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 spec, no caller cap — passes",
query: &metricsview.Query{TimeRange: tr(365)},
},
{
name: "spec cap with range under cap — passes",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(7)},
},
{
name: "spec cap exceeded — fails",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(60)},
wantErr: "max_query_time_range",
},
{
name: "caller cap tighter than spec — caller wins",
spec: "P90D",
callerCap: 30,
query: &metricsview.Query{TimeRange: tr(60)},
wantErr: "rill.ai.max_time_range_days",
},
{
name: "caller cap with range under cap — passes",
spec: "",
callerCap: 30,
query: &metricsview.Query{TimeRange: tr(7)},
},
{
name: "spec cap exceeded by comparison range — fails",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(7), ComparisonTimeRange: tr(60)},
wantErr: "max_query_time_range",
},
{
name: "spec cap exceeded by primary, comparison fits — fails",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(60), ComparisonTimeRange: tr(7)},
wantErr: "max_query_time_range",
},
{
name: "spec set but no time range on query — passes",
spec: "P30D",
query: &metricsview.Query{},
},
{
name: "require_time_range without time range — fails",
query: &metricsview.Query{QueryLimits: &metricsview.QueryLimits{
RequireTimeRange: true,
}},
wantErr: "valid time_range",
},
{
name: "calendar duration P1M ≈ 30 days — 31-day range exceeds",
spec: "P1M",
query: &metricsview.Query{TimeRange: tr(31)},
wantErr: "max_query_time_range",
},
{
name: "spec error message names the property",
spec: "P30D",
query: &metricsview.Query{TimeRange: tr(60)},
wantErr: "configured via the metrics view's max_query_time_range property",
},
}

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)
})
}
}
20 changes: 20 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,23 @@ 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)
}
if err := duration.ValidateISO8601(tmp.MaxQueryTimeRange, true, false); err != nil {
return fmt.Errorf(`invalid "max_query_time_range": %w`, err)
}
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 +886,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
82 changes: 82 additions & 0 deletions runtime/parser/parse_metrics_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -832,3 +832,85 @@ rollups:
})
}
}

func TestMetricsViewMaxQueryTimeRange(t *testing.T) {
mvBody := func(maxRange string) string {
s := `
type: metrics_view
version: 1
model: m1
dimensions:
- name: foo
column: id
measures:
- name: count
expression: COUNT(*)
`
if maxRange != "" {
s += "max_query_time_range: " + maxRange + "\n"
}
return s
}

t.Run("valid", func(t *testing.T) {
files := map[string]string{
`rill.yaml`: ``,
`models/m1.sql`: `SELECT 1 AS id`,
`metrics_views/mv1.yaml`: mvBody("P90D"),
}
ctx := context.Background()
repo := makeRepo(t, files)
p, err := Parse(ctx, repo, "", "", "duckdb", true)
require.NoError(t, err)
require.Empty(t, p.Errors)

var mv *runtimev1.MetricsViewSpec
for _, r := range p.Resources {
if r.MetricsViewSpec != nil {
mv = r.MetricsViewSpec
break
}
}
require.NotNil(t, mv)
require.Equal(t, "P90D", mv.MaxQueryTimeRange)
})

t.Run("unset", func(t *testing.T) {
files := map[string]string{
`rill.yaml`: ``,
`models/m1.sql`: `SELECT 1 AS id`,
`metrics_views/mv1.yaml`: mvBody(""),
}
ctx := context.Background()
repo := makeRepo(t, files)
p, err := Parse(ctx, repo, "", "", "duckdb", true)
require.NoError(t, err)
require.Empty(t, p.Errors)

var mv *runtimev1.MetricsViewSpec
for _, r := range p.Resources {
if r.MetricsViewSpec != nil {
mv = r.MetricsViewSpec
break
}
}
require.NotNil(t, mv)
require.Empty(t, mv.MaxQueryTimeRange)
})

for _, bad := range []string{"garbage", "rill-PM", "inf", "PT12H", "PT1H30M", "P1DT6H"} {
t.Run("invalid_"+bad, func(t *testing.T) {
files := map[string]string{
`rill.yaml`: ``,
`models/m1.sql`: `SELECT 1 AS id`,
`metrics_views/mv1.yaml`: mvBody(bad),
}
ctx := context.Background()
repo := makeRepo(t, files)
p, err := Parse(ctx, repo, "", "", "duckdb", true)
require.NoError(t, err)
require.NotEmpty(t, p.Errors)
require.Contains(t, p.Errors[0].Message, "max_query_time_range")
})
}
}
3 changes: 3 additions & 0 deletions runtime/parser/schema/project.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,9 @@ definitions:
first_month_of_year:
type: integer
description: 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:
type: string
description: '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:
type: array
description: Relates to exploring segments or dimensions of your data and filtering the dashboard
Expand Down
Loading
Loading