Files
go-redis/timeseries_querylabels_integration_test.go
Nedyalko Dyakovandofekshenawa ce8d3b053f 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>
2026-07-28 19:40:04 +03:00

109 lines
3.9 KiB
Go

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:"))
})
})
}
})