Compare commits

...

4 Commits

Author SHA1 Message Date
Nityananda Gohain
599ae273c9 Merge branch 'main' into issue_5123_2 2026-07-06 09:03:03 -07:00
nityanandagohain
36edbeca38 fix: move error from normalize to validate 2026-07-06 21:32:00 +05:30
Nityananda Gohain
0436655a43 Merge branch 'main' into issue_5123_2 2026-06-08 12:34:18 +05:30
nityanandagohain
301d496092 chore: add normalize for QueryRangeRequest 2026-06-05 12:31:19 +05:30
7 changed files with 173 additions and 81 deletions

View File

@@ -178,6 +178,8 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
return
}
queryRangeRequest.Normalize()
// Validate the query request
if err := queryRangeRequest.Validate(); err != nil {
render.Error(rw, err)

View File

@@ -91,11 +91,10 @@ func New(
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Normalize Start/End to ms. UnmarshalJSON covers HTTP requests; callers
// that build the request programmatically skip it, so this is the catch-all
// (idempotent for the already-normalized path).
req.Normalize()
event := &qbtypes.QBEvent{
Version: "v5",
@@ -456,10 +455,9 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Catch-all normalization for programmatic callers (see QueryRange). End is
// 0 here for the open-ended stream, which Normalize leaves untouched.
req.Normalize()
event := &qbtypes.QBEvent{
Version: "v5",

View File

@@ -33,28 +33,6 @@ func ToNanoSecs(epoch uint64) uint64 {
return temp * uint64(math.Pow(10, float64(19-count)))
}
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
// That would make graph look nice but "nice" but should be less important than the usefulness.
func RecommendedStepInterval(start, end uint64) uint64 {

View File

@@ -60,51 +60,3 @@ func TestToNanoSecs(t *testing.T) {
})
}
}
func TestToMilliSecs(t *testing.T) {
tests := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
epoch: 1672531200, // seconds
expected: 1672531200000, // * 10^3
},
{
name: "13-digit Unix timestamp (milliseconds) - already ms",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "16-digit Unix timestamp (microseconds)",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "19-digit Unix timestamp (nanoseconds)",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Unix epoch start - zero is unchanged",
epoch: 0,
expected: 0,
},
{
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
epoch: 1716638400,
expected: 1716638400000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToMilliSecs(tt.epoch)
if result != tt.expected {
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
}
})
}
}

View File

@@ -614,7 +614,14 @@ func (r *QueryRangeRequest) SkipFillGaps(name string) bool {
return false
}
// UnmarshalJSON implements custom JSON unmarshaling to disallow unknown fields.
// Normalize coerces Start and End to epoch milliseconds, inferring the source
// resolution (s/ms/µs/ns) from each value's magnitude. Lets downstream consumers
// assume ms regardless of what the caller sent. This is a pure transformation;
// range validation of the resulting values lives in Validate.
func (r *QueryRangeRequest) Normalize() {
r.Start, r.End = toMilliSecs(r.Start), toMilliSecs(r.End)
}
func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Define a type alias to avoid infinite recursion
type Alias QueryRangeRequest
@@ -655,6 +662,10 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Copy the decoded values back to the original struct
*r = QueryRangeRequest(temp)
// Coerce Start/End to ms at decode time for HTTP requests. Range validation
// of the coerced values happens later in Validate.
r.Normalize()
return nil
}
@@ -708,3 +719,19 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
return canDefaultZero
}
// toMilliSecs scales an epoch to milliseconds based on its magnitude: seconds are
// scaled up, micro/nanoseconds down, milliseconds left as-is. Zero is returned
// unchanged.
func toMilliSecs(epoch uint64) uint64 {
switch {
case epoch < 1e12: // seconds
return epoch * 1_000
case epoch < 1e15: // milliseconds
return epoch
case epoch < 1e18: // microseconds
return epoch / 1_000
default: // nanoseconds
return epoch / 1_000_000
}
}

View File

@@ -1903,3 +1903,114 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestQueryRangeRequest_Normalize(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantStart uint64
wantEnd uint64
}{
{
name: "seconds are scaled up to ms",
start: 1672531200, // 2023-01-01 in seconds
end: 1716638400, // 2024-05-25 in seconds
wantStart: 1672531200000, // * 10^3
wantEnd: 1716638400000,
},
{
name: "milliseconds pass through unchanged",
start: 1672531200000,
end: 1716638400000,
wantStart: 1672531200000,
wantEnd: 1716638400000,
},
{
name: "microseconds are scaled down to ms",
start: 1672531200000000, // µs
end: 1716638400000000,
wantStart: 1672531200000, // / 10^3
wantEnd: 1716638400000,
},
{
name: "nanoseconds are scaled down to ms",
start: 1672531200000000000, // ns
end: 1716638400000000000,
wantStart: 1672531200000, // / 10^6
wantEnd: 1716638400000,
},
{
name: "zero end (open-ended stream) is left untouched",
start: 1672531200000,
end: 0,
wantStart: 1672531200000,
wantEnd: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{Start: tt.start, End: tt.end}
r.Normalize()
assert.Equal(t, tt.wantStart, r.Start)
assert.Equal(t, tt.wantEnd, r.End)
})
}
}
func TestQueryRangeRequest_ValidateTimeRange(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantErr bool
}{
{
name: "in-range ms timestamps pass",
start: 1672531200000, // 2023-01-01
end: 1716638400000, // 2024-05-25
},
{
name: "start below the 1990 bound is rejected",
start: 1000, // ~1970
end: 1716638400000,
wantErr: true,
},
{
name: "end beyond the 2100 bound is rejected",
start: 1672531200000,
end: 5_000_000_000_000, // ~year 2128 in ms
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{
Start: tt.start,
End: tt.end,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{{Expression: "count()"}},
},
},
},
},
}
err := r.Validate()
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), "outside the supported range")
return
}
require.NoError(t, err)
})
}
}

View File

@@ -10,6 +10,13 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// minEpochMs and maxEpochMs bound a plausible ms timestamp to
// 1990-01-01 .. 2100-01-01, used to reject malformed Start/End values.
minEpochMs uint64 = 631_152_000_000
maxEpochMs uint64 = 4_102_444_800_000
)
// getQueryIdentifier returns a friendly identifier for a query based on its type and name/content.
func getQueryIdentifier(envelope QueryEnvelope, index int) string {
name := envelope.GetQueryName()
@@ -527,7 +534,24 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
// Validate validates the entire query range request.
func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
// Validate time range
// Validate time range. Start/End are assumed to be normalized to ms (see
// QueryRangeRequest.Normalize); reject non-zero values outside the plausible
// 1990-2100 range as malformed.
if r.Start != 0 && (r.Start < minEpochMs || r.Start > maxEpochMs) {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"start timestamp %d is outside the supported range (1990-2100)",
r.Start,
)
}
if r.End != 0 && (r.End < minEpochMs || r.End > maxEpochMs) {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"end timestamp %d is outside the supported range (1990-2100)",
r.End,
)
}
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,