1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00

feat(timeseries): add TS.QUERYLABELS support (#3926)

* feat(timeseries): add TS.QUERYLABELS support (Redis 8.10)

- TSQueryLabels lists label names, TSQueryLabelValues the values of
  one label, across series matching optional TS.QUERYINDEX-style
  filters
- the FILTER token is emitted only when expressions are given (the
  server rejects a bare FILTER); expressions pass through verbatim
  and the VALUES label is matched byte-exactly
- replies are unordered, server-deduplicated string sets under both
  RESP2 and RESP3; empty replies are success, not errors
- wire-format unit tests plus a RESP2/RESP3 integration test that
  gates on the live server supporting the command
- runnable example under example/ts-querylabels

* fix(tests): use skip for tests

* test(timeseries): version-gate TS.QUERYLABELS

Replace the standalone probe test (command probing plus the
REDIS_QUERYLABELS_TEST_ADDR env var) with Ginkgo specs in the main
suite, gated on SkipBeforeRedisVersion("8.10"). The plain go test also
called the Ginkgo-based SkipBeforeRedisVersion helper, which panics
outside the suite when it actually skips; moving the specs into the
suite fixes that. Covers both command forms under RESP2 and RESP3.

* chore(deps): bump grpc to v1.82.1 in otel examples

govulncheck fails every PR on GO-2026-6061 in google.golang.org/grpc
v1.80.0 (indirect via the OTLP exporters); v1.82.1 carries the fix.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
This commit is contained in:
Nedyalko Dyakov
2026-07-28 19:40:04 +03:00
committed by GitHub
parent 279615082b
commit ce8d3b053f
7 changed files with 396 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# TS.QUERYLABELS example
This example demonstrates `TSQueryLabels` and `TSQueryLabelValues`, the
client bindings for the two forms of `TS.QUERYLABELS` (Redis 8.10+):
- `TSQueryLabels(ctx, filters)` — the set of label names present on the
matching time series (`TS.QUERYLABELS LABELS [FILTER ...]`).
- `TSQueryLabelValues(ctx, label, filters)` — the set of values assigned to
one label across the matching series
(`TS.QUERYLABELS VALUES label [FILTER ...]`).
Together with the existing `TSQueryIndex` they form the drill-down flow
dashboarding tools such as Grafana use to populate variable dropdowns:
label names → values of the chosen label → matching series keys.
Key points shown by the example:
- Filters use the same expression language as `TSQueryIndex`/`TSMRange` and
are passed to the server verbatim; passing no filters queries all indexed
series.
- Replies are unordered, deduplicated sets of strings; the `LABELS` form
includes the label names used in the filter itself.
- A label carried by no matching series yields an empty reply, not an error.
## Prerequisites
Redis 8.10+ with the TimeSeries module, listening on `localhost:6379`.
## Run
```shell
go run .
```
Expected output (order within the collections may differ):
```
label names for type=sensor: [location type unit]
locations for type=sensor: [bedroom kitchen]
kitchen sensor series: [ts:hum:kitchen ts:temp:kitchen]
values of an absent label: 0
label names, all series: [location type unit]
```

View File

@@ -0,0 +1,12 @@
module github.com/redis/go-redis/example/ts-querylabels
go 1.24
replace github.com/redis/go-redis/v9 => ../..
require github.com/redis/go-redis/v9 v9.21.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
)

View File

@@ -0,0 +1,20 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -0,0 +1,89 @@
// Example demonstrating TS.QUERYLABELS: discovering label names and label
// values across time series.
//
// TS.QUERYLABELS (Redis 8.10+) completes the drill-down flow dashboarding
// tools such as Grafana use to populate variable dropdowns:
//
// 1. TSQueryLabels - all label names for a group of series
// 2. TSQueryLabelValues - all values of a chosen label for that group
// 3. TSQueryIndex - all keys matching the fully-selected filter
//
// Requires Redis 8.10+ with the TimeSeries module.
package main
import (
"context"
"fmt"
"log"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close()
keys := seed(ctx, rdb)
defer rdb.Del(ctx, keys...)
// Step 1: first dropdown - which labels does the sensor group carry?
// The reply is unordered, deduplicated, and includes the label used in
// the filter itself ("type").
labels, err := rdb.TSQueryLabels(ctx, []string{"type=sensor"}).Result()
if err != nil {
log.Fatalf("TS.QUERYLABELS LABELS: %v", err)
}
fmt.Printf("label names for type=sensor: %v\n", labels)
// Step 2: second dropdown - which values does "location" take in that
// group? Series without the label contribute nothing.
locations, err := rdb.TSQueryLabelValues(ctx, "location", []string{"type=sensor"}).Result()
if err != nil {
log.Fatalf("TS.QUERYLABELS VALUES: %v", err)
}
fmt.Printf("locations for type=sensor: %v\n", locations)
// Step 3: third dropdown - the keys matching the fully-selected filter
// (existing TS.QUERYINDEX).
series, err := rdb.TSQueryIndex(ctx, []string{"type=sensor", "location=kitchen"}).Result()
if err != nil {
log.Fatalf("TS.QUERYINDEX: %v", err)
}
fmt.Printf("kitchen sensor series: %v\n", series)
// A label that no matching series carries yields an empty reply, not an
// error - dashboards keep working.
missing, err := rdb.TSQueryLabelValues(ctx, "rack", []string{"type=sensor"}).Result()
if err != nil {
log.Fatalf("TS.QUERYLABELS VALUES rack: %v", err)
}
fmt.Printf("values of an absent label: %d\n", len(missing))
// Passing no filters queries every indexed series in the database.
all, err := rdb.TSQueryLabels(ctx, nil).Result()
if err != nil {
log.Fatalf("TS.QUERYLABELS LABELS (no filter): %v", err)
}
fmt.Printf("label names, all series: %v\n", all)
}
func seed(ctx context.Context, rdb *redis.Client) []string {
series := map[string]map[string]string{
"ts:temp:kitchen": {"type": "sensor", "location": "kitchen", "unit": "celsius"},
"ts:temp:bedroom": {"type": "sensor", "location": "bedroom", "unit": "celsius"},
"ts:hum:kitchen": {"type": "sensor", "location": "kitchen"},
"ts:build:count": {"type": "counter"},
}
keys := make([]string, 0, len(series))
for key, labels := range series {
if err := rdb.TSCreateWithArgs(ctx, key, &redis.TSOptions{Labels: labels}).Err(); err != nil {
log.Fatalf("TS.CREATE %s (requires Redis 8.10+ with timeseries): %v", key, err)
}
keys = append(keys, key)
}
return keys
}

View File

@@ -31,6 +31,8 @@ type TimeseriesCmdable interface {
TSInfoWithArgs(ctx context.Context, key string, options *TSInfoOptions) *MapStringInterfaceCmd
TSMAdd(ctx context.Context, ktvSlices [][]interface{}) *IntSliceCmd
TSQueryIndex(ctx context.Context, filterExpr []string) *StringSliceCmd
TSQueryLabels(ctx context.Context, filterExpr []string) *StringSliceCmd
TSQueryLabelValues(ctx context.Context, label string, filterExpr []string) *StringSliceCmd
TSRevRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd
TSRevRangeWithArgs(ctx context.Context, key string, fromTimestamp int, toTimestamp int, options *TSRevRangeOptions) *TSTimestampValueSliceCmd
TSRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd
@@ -717,6 +719,53 @@ func (c cmdable) TSQueryIndex(ctx context.Context, filterExpr []string) *StringS
return cmd
}
// TSQueryLabels - Returns the set of label names present on the time series
// matching the filter expressions. Passing no filter expressions queries all
// indexed series. The reply is unordered and already deduplicated by the
// server; it includes the label names used in the filter itself, and an
// empty reply is a valid result, not an error.
// filterExpr uses the same filter language as TSQueryIndex and is passed to
// the server verbatim. Available since Redis 8.10.
// For more information - https://redis.io/commands/ts.querylabels/
func (c cmdable) TSQueryLabels(ctx context.Context, filterExpr []string) *StringSliceCmd {
args := []interface{}{"TS.QUERYLABELS", "LABELS"}
args = appendTSFilter(args, filterExpr)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// TSQueryLabelValues - Returns the set of values assigned to the given label
// name across the time series matching the filter expressions. Passing no
// filter expressions queries all indexed series. The label name is matched
// byte-exactly; a label present on no matching series yields an empty reply,
// not an error. The reply is unordered and already deduplicated by the
// server.
// filterExpr uses the same filter language as TSQueryIndex and is passed to
// the server verbatim. Available since Redis 8.10.
// For more information - https://redis.io/commands/ts.querylabels/
func (c cmdable) TSQueryLabelValues(ctx context.Context, label string, filterExpr []string) *StringSliceCmd {
args := []interface{}{"TS.QUERYLABELS", "VALUES", label}
args = appendTSFilter(args, filterExpr)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// appendTSFilter appends the FILTER token followed by the filter expressions,
// or nothing when no expressions are given: the server rejects a bare FILTER
// token, and omitting it is the documented way to query all indexed series.
func appendTSFilter(args []interface{}, filterExpr []string) []interface{} {
if len(filterExpr) == 0 {
return args
}
args = append(args, "FILTER")
for _, f := range filterExpr {
args = append(args, f)
}
return args
}
// TSRevRange - Returns a range of samples from a time-series key in reverse order.
// For more information - https://redis.io/commands/ts.revrange/
func (c cmdable) TSRevRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd {

View File

@@ -0,0 +1,108 @@
package redis_test
import (
"context"
"fmt"
. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
"github.com/redis/go-redis/v9"
)
// TS.QUERYLABELS ships in RedisTimeSeries 8.10. The specs gate on
// SkipBeforeRedisVersion, which compares major.minor as integers, and
// exercise both command forms under RESP2 (array reply) and RESP3 (set
// reply) against the suite's standard server.
var _ = Describe("TS.QUERYLABELS", Label("timeseries", "tsquerylabels"), func() {
ctx := context.TODO()
for _, protocol := range []int{2, 3} {
protocol := protocol
Describe(fmt.Sprintf("RESP%d", protocol), func() {
var client *redis.Client
// The command aggregates over every indexed series in the
// database, so scope assertions with a filter on a tag value
// unique to this spec group.
group := fmt.Sprintf("querylabels-test-resp%d", protocol)
groupFilter := []string{"test_group=" + group}
keys := []string{
fmt.Sprintf("querylabels-test:%d:1", protocol),
fmt.Sprintf("querylabels-test:%d:2", protocol),
fmt.Sprintf("querylabels-test:%d:3", protocol),
}
BeforeEach(func() {
SkipBeforeRedisVersion("8.10", "TS.QUERYLABELS requires Redis 8.10+")
client = redis.NewClient(&redis.Options{Addr: ":6379", Protocol: protocol})
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())
mustCreate := func(key string, labels map[string]string) {
Expect(client.TSCreateWithArgs(ctx, key, &redis.TSOptions{Labels: labels}).Err()).NotTo(HaveOccurred())
}
mustCreate(keys[0], map[string]string{"test_group": group, "location": "kitchen", "unit": "celsius"})
mustCreate(keys[1], map[string]string{"test_group": group, "location": "bedroom", "unit": "celsius"})
mustCreate(keys[2], map[string]string{"test_group": group, "location": "kitchen"})
})
AfterEach(func() {
if client == nil {
return
}
_ = client.Del(ctx, keys...).Err()
Expect(client.Close()).NotTo(HaveOccurred())
client = nil
})
It("lists every distinct label name across the matching series", func() {
// The reply includes the label name used in the filter
// itself and is unordered and deduplicated.
labels, err := client.TSQueryLabels(ctx, groupFilter).Result()
Expect(err).NotTo(HaveOccurred())
Expect(labels).To(ConsistOf("test_group", "location", "unit"))
})
It("lists the deduplicated union of a label's values", func() {
values, err := client.TSQueryLabelValues(ctx, "location", groupFilter).Result()
Expect(err).NotTo(HaveOccurred())
Expect(values).To(ConsistOf("kitchen", "bedroom"))
// Series without the label contribute nothing.
values, err = client.TSQueryLabelValues(ctx, "unit", groupFilter).Result()
Expect(err).NotTo(HaveOccurred())
Expect(values).To(ConsistOf("celsius"))
})
It("returns an empty reply for a label on no matching series", func() {
values, err := client.TSQueryLabelValues(ctx, "no-such-label", groupFilter).Result()
Expect(err).NotTo(HaveOccurred())
Expect(values).To(BeEmpty())
})
It("matches label names byte-exactly", func() {
// A case variant is a different label.
values, err := client.TSQueryLabelValues(ctx, "Location", groupFilter).Result()
Expect(err).NotTo(HaveOccurred())
Expect(values).To(BeEmpty())
})
It("queries all indexed series when the filter is omitted", func() {
labels, err := client.TSQueryLabels(ctx, nil).Result()
Expect(err).NotTo(HaveOccurred())
// FlushDB in BeforeEach makes this spec's series the only
// indexed ones in the database.
Expect(labels).To(ConsistOf("test_group", "location", "unit"))
})
It("propagates server-side filter errors as-is", func() {
// A filter list without an inclusive matcher is rejected by
// the server (TSDB: prefix), not by the client.
err := client.TSQueryLabels(ctx, []string{"test_group!=" + group}).Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("TSDB:"))
})
})
}
})

View File

@@ -0,0 +1,75 @@
package redis
import (
"context"
"reflect"
"testing"
)
// TestTSQueryLabelsArgs pins the wire format of both TS.QUERYLABELS forms.
// The FILTER token must be emitted only when at least one filter expression
// is given (the server rejects a bare FILTER), filter expressions must be
// passed verbatim and in order, and the VALUES label must not be normalized.
func TestTSQueryLabelsArgs(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
call func(c cmdable) Cmder
want []interface{}
}{
{
name: "labels_without_filter_omits_filter_token",
call: func(c cmdable) Cmder { return c.TSQueryLabels(ctx, nil) },
want: []interface{}{"TS.QUERYLABELS", "LABELS"},
},
{
name: "labels_with_empty_filter_slice_omits_filter_token",
call: func(c cmdable) Cmder { return c.TSQueryLabels(ctx, []string{}) },
want: []interface{}{"TS.QUERYLABELS", "LABELS"},
},
{
name: "labels_with_one_filter",
call: func(c cmdable) Cmder { return c.TSQueryLabels(ctx, []string{"type=sensor"}) },
want: []interface{}{"TS.QUERYLABELS", "LABELS", "FILTER", "type=sensor"},
},
{
name: "labels_filters_verbatim_and_ordered",
call: func(c cmdable) Cmder {
return c.TSQueryLabels(ctx, []string{"type=sensor", "location!=", "l=(a,b)"})
},
want: []interface{}{"TS.QUERYLABELS", "LABELS", "FILTER", "type=sensor", "location!=", "l=(a,b)"},
},
{
name: "values_without_filter_omits_filter_token",
call: func(c cmdable) Cmder { return c.TSQueryLabelValues(ctx, "location", nil) },
want: []interface{}{"TS.QUERYLABELS", "VALUES", "location"},
},
{
name: "values_with_filters",
call: func(c cmdable) Cmder {
return c.TSQueryLabelValues(ctx, "location", []string{"type=sensor"})
},
want: []interface{}{"TS.QUERYLABELS", "VALUES", "location", "FILTER", "type=sensor"},
},
{
name: "values_label_not_normalized",
call: func(c cmdable) Cmder { return c.TSQueryLabelValues(ctx, " LoCaTiOn ", nil) },
want: []interface{}{"TS.QUERYLABELS", "VALUES", " LoCaTiOn "},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var captured Cmder
c := captureCmdable(&captured)
cmd := tt.call(c)
if cmd == nil {
t.Fatalf("command builder returned nil")
}
if !reflect.DeepEqual(cmd.Args(), tt.want) {
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
}
})
}
}