diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b58d6f..17d6593 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,12 @@ name: Release Helm Charts on: push: branches: [ "main" ] + tags: [ "v*" ] +# contents: write is required to create GitHub Releases on tags. +# packages: write is required to push chart packages to GHCR. permissions: - contents: read + contents: write packages: write jobs: @@ -13,55 +16,80 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 2 - + - name: Set up Helm uses: azure/setup-helm@v3 with: version: v3.11.0 - - name: Detect changed charts + - name: Select charts to release id: detect run: | - CHANGED_FILES=$(git diff --name-only HEAD^ HEAD || true) - CHART_FILES=$(echo "$CHANGED_FILES" | grep "^charts/" || true) + if [ "${{ github.ref_type }}" = "tag" ]; then + # On a tag, release every chart in the repository. + UNIQUE_CHARTS=$(find charts -mindepth 1 -maxdepth 1 -type d \ + -exec test -f '{}/Chart.yaml' ';' -print \ + | cut -d/ -f2 | sort | uniq) + else + # On a branch push, release only charts changed in this commit. + CHANGED_FILES=$(git diff --name-only HEAD^ HEAD || true) + CHART_FILES=$(echo "$CHANGED_FILES" | grep "^charts/" || true) - if [ -z "$CHART_FILES" ]; then - echo "No changes in 'charts/' folder." - echo "unique_charts=" >> $GITHUB_OUTPUT - exit 0 - fi + if [ -z "$CHART_FILES" ]; then + echo "No changes in 'charts/' folder." + echo "unique_charts=" >> "$GITHUB_OUTPUT" + exit 0 + fi - UNIQUE_CHARTS=$(echo "$CHART_FILES" \ - | cut -d/ -f2 \ - | sort \ - | uniq) + UNIQUE_CHARTS=$(echo "$CHART_FILES" | cut -d/ -f2 | sort | uniq) + fi - echo "unique_charts=$UNIQUE_CHARTS" >> $GITHUB_OUTPUT + echo "unique_charts=$UNIQUE_CHARTS" >> "$GITHUB_OUTPUT" - - name: Package and push changed charts + - name: Package and push charts if: ${{ steps.detect.outputs.unique_charts != '' }} env: GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} GHCR_OWNER: ${{ github.repository_owner }} run: | OWNER_LC=$(printf '%s' "$GHCR_OWNER" | tr '[:upper:]' '[:lower:]') + + # On a tag (e.g. v1.2.3), version every chart to the tag (strip the leading v). + VERSION_ARGS="" + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${GITHUB_REF_NAME#v}" + VERSION_ARGS="--version $VERSION --app-version $VERSION" + fi + + mkdir -p dist + printf '%s' "$GHCR_TOKEN" | helm registry login ghcr.io \ + -u "${{ github.actor }}" \ + --password-stdin + for CHART_NAME in ${{ steps.detect.outputs.unique_charts }}; do - echo "Packaging & Publishing chart: $CHART_NAME" + echo "Packaging & publishing chart: $CHART_NAME" CHART_PATH="charts/$CHART_NAME" - helm package "$CHART_PATH" --destination "$CHART_PATH" - PACKAGE_TGZ=$(ls "$CHART_PATH"/*.tgz) - printf '%s' "$GHCR_TOKEN" | helm registry login ghcr.io \ - -u "${{ github.actor }}" \ - --password-stdin + helm package "$CHART_PATH" --destination dist $VERSION_ARGS + PACKAGE_TGZ=$(ls dist/"$CHART_NAME"-*.tgz) helm push "$PACKAGE_TGZ" "oci://ghcr.io/$OWNER_LC" echo "Published $CHART_NAME to GHCR." done - - name: No chart changes + - name: Create GitHub Release + # Only cut a GitHub Release on tags, attaching the packaged charts. + if: ${{ github.ref_type == 'tag' && steps.detect.outputs.unique_charts != '' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "$GITHUB_REF_NAME" dist/*.tgz \ + --title "$GITHUB_REF_NAME" \ + --generate-notes + + - name: No charts to release if: ${{ steps.detect.outputs.unique_charts == '' }} - run: echo "No chart changes to release." + run: echo "No charts to release." diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe67b0..2ba85ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- In-memory query-result cache for `promclick-proxy`. When `cache.enabled: true`, + `/api/v1/query` and `/api/v1/query_range` responses are cached in an LRU keyed + by `(query, start, end, step)` with per-entry TTL, and concurrent identical + queries are collapsed into a single evaluation via singleflight. Windows whose + end is within `cache.max_freshness` of now are served but not stored (their + data is still being written). Config: `cache.{enabled,max_size,ttl,max_freshness}` + (previously present but unwired; now active, default disabled). New package + `proxy/cache`. +- `match[]` (plus best-effort `start`/`end`) support on `/api/v1/labels` and + `/api/v1/label/{name}/values`, so Grafana template variables such as + `label_values(up, instance)` are scoped to the selected metric instead of + returning every value across the whole TSDB. `POST` is now accepted on + `/api/v1/label/{name}/values` alongside `GET`, matching Prometheus. +- `/api/v1/metadata` is now populated (one `unknown`-typed entry per metric name) + and honours the `metric` and `limit` parameters, so Grafana's metric browser + and autocomplete work. Previously returned an empty object. - `NOTICE` file crediting the upstream [PromClick Authors](https://github.com/PromClick/PromClick) and recording the Digitalis.io distribution copyright, preserving Apache-2.0 attribution. - GitHub Actions workflow (`.github/workflows/build.yml`) that builds the multi-arch @@ -19,6 +35,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `.dockerignore` to shrink the build context. ### Changed +- `/api/v1/series` now parses each `match[]` with the Prometheus parser, applies + **all** label matchers (not just a single metric name), supports multiple + `match[]` selectors, deduplicates by series fingerprint, and answers from the + in-memory label cache when a concrete metric name is present. Its ClickHouse + fallback now decodes the JSON-string `labels` column correctly (the previous + hand-rolled parser silently returned empty label sets for `String` label + columns). - Container image references now point to `ghcr.io/digitalis-io/promclick` instead of the upstream `quay.io/hinski/promclick` (Helm chart values, `docker-compose.yaml`, README examples). @@ -30,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 making multi-arch image builds substantially faster. ### Security +- Reused the existing `chEscape` for all matcher values rendered into ClickHouse + metadata/series SQL (equality, inequality and regex conditions), keeping the + `match[]`-driven query paths free of SQL injection. - Fixed an SQL-injection vector in the OTel metadata/series handlers: `chEscape` now escapes backslashes before single quotes so ClickHouse string literals cannot be broken out of via a trailing backslash in request parameters. diff --git a/README.md b/README.md index 491fce8..7f1eb2a 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,12 @@ labels: cache_ttl: "60s" # refresh interval cache_max_series: 50000 +cache: # in-memory query-result cache (off by default) + enabled: true # cache /query and /query_range responses + max_size: 1000 # max cached responses (LRU) + ttl: "60s" # entry lifetime + max_freshness: "60s" # skip caching windows whose end is within this of now + downsampling: # read-only - proxy uses tiers for query routing enabled: true # but does NOT create tables/MVs tiers: @@ -645,7 +651,8 @@ If you already run ClickHouse, PromClick gives you infinite Prometheus retention - [ ] **Ruler** - PromQL rule evaluation engine with Alertmanager integration - [x] **Helm chart** - one-click deploy to Kubernetes - [ ] **K8s operator** - CRD-based management of PromClick instances -- [ ] **Redis / Memcached query cache** - sub-millisecond responses for repeated queries +- [x] **In-memory query cache** - LRU + TTL + singleflight (`cache.enabled`) +- [ ] **Redis / Memcached query cache** - shared/distributed cache across proxies - [ ] Subquery support (`metric[1h:5m]`) - [ ] Native histograms - [ ] TLS diff --git a/charts/promclick-chart/templates/_helpers.tpl b/charts/promclick-chart/templates/_helpers.tpl index e901f23..aea9ea1 100644 --- a/charts/promclick-chart/templates/_helpers.tpl +++ b/charts/promclick-chart/templates/_helpers.tpl @@ -49,6 +49,12 @@ labels: cache_ttl: "60s" cache_max_series: 50000 +cache: + enabled: false + max_size: 1000 + ttl: "60s" + max_freshness: "60s" + cors: allow_origin: "*" diff --git a/deploy/proxy.yaml b/deploy/proxy.yaml index 74693f1..66157ca 100644 --- a/deploy/proxy.yaml +++ b/deploy/proxy.yaml @@ -13,6 +13,15 @@ labels: cache_ttl: "60s" cache_max_series: 50000 +# In-memory query-result cache (off by default). Caches /query and +# /query_range responses keyed by (query, start, end, step) with singleflight +# de-duplication of concurrent identical queries. +cache: + enabled: false + max_size: 1000 # max cached responses (LRU) + ttl: "60s" # entry lifetime + max_freshness: "60s" # don't cache windows whose end is within this of now + cors: allow_origin: "*" diff --git a/proxy/cache/cache.go b/proxy/cache/cache.go new file mode 100644 index 0000000..062b2fa --- /dev/null +++ b/proxy/cache/cache.go @@ -0,0 +1,173 @@ +// Package cache implements an in-memory query-result cache for the proxy. +// +// It stores pre-serialised Prometheus API response bytes keyed by the query +// parameters, so a cache hit is a direct write to the client with no +// re-evaluation and no re-marshalling. A singleflight group collapses +// concurrent identical misses into a single evaluation, which is where most of +// the value comes from under dashboard load (many panels / many viewers issuing +// the same query at once). +package cache + +import ( + "container/list" + "encoding/binary" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/cespare/xxhash/v2" + "golang.org/x/sync/singleflight" +) + +// Entry is a cached, fully rendered API response. +type Entry struct { + Body []byte + ContentType string +} + +// item is the LRU list payload. +type item struct { + key uint64 + entry Entry + expiresAt time.Time +} + +// ResultCache is a size-bounded, TTL-expiring LRU of rendered responses with an +// embedded singleflight group. It is safe for concurrent use. +type ResultCache struct { + mu sync.Mutex + maxSize int + ttl time.Duration + ll *list.List // front = most-recently used + items map[uint64]*list.Element // key → *list.Element(item) + sf singleflight.Group + + hits atomic.Uint64 + misses atomic.Uint64 + + // now is overridable in tests; nil means time.Now. + now func() time.Time +} + +// New creates a ResultCache. maxSize < 1 is treated as 1. +func New(maxSize int, ttl time.Duration) *ResultCache { + if maxSize < 1 { + maxSize = 1 + } + return &ResultCache{ + maxSize: maxSize, + ttl: ttl, + ll: list.New(), + items: make(map[uint64]*list.Element, maxSize), + } +} + +func (c *ResultCache) clock() time.Time { + if c.now != nil { + return c.now() + } + return time.Now() +} + +// Key derives a stable cache key from the query and its time bounds. Timestamps +// are in milliseconds; step is the resolution in milliseconds (0 for instant). +func Key(query string, startMs, endMs, stepMs int64) uint64 { + var d xxhash.Digest + d.Reset() + _, _ = d.WriteString(query) + var buf [24]byte + binary.LittleEndian.PutUint64(buf[0:8], uint64(startMs)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(endMs)) + binary.LittleEndian.PutUint64(buf[16:24], uint64(stepMs)) + _, _ = d.Write(buf[:]) + return d.Sum64() +} + +// Get returns a live (non-expired) entry, promoting it to most-recently-used. +func (c *ResultCache) Get(key uint64) (Entry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return Entry{}, false + } + it := el.Value.(*item) + if c.clock().After(it.expiresAt) { + c.removeElement(el) + return Entry{}, false + } + c.ll.MoveToFront(el) + return it.entry, true +} + +// Set inserts or updates an entry, evicting the least-recently-used item when +// the cache is over capacity. +func (c *ResultCache) Set(key uint64, e Entry) { + c.mu.Lock() + defer c.mu.Unlock() + exp := c.clock().Add(c.ttl) + if el, ok := c.items[key]; ok { + it := el.Value.(*item) + it.entry = e + it.expiresAt = exp + c.ll.MoveToFront(el) + return + } + el := c.ll.PushFront(&item{key: key, entry: e, expiresAt: exp}) + c.items[key] = el + for c.ll.Len() > c.maxSize { + if back := c.ll.Back(); back != nil { + c.removeElement(back) + } + } +} + +// removeElement removes el from both the list and the map. Caller holds c.mu. +func (c *ResultCache) removeElement(el *list.Element) { + c.ll.Remove(el) + delete(c.items, el.Value.(*item).key) +} + +// Fetch returns a cached entry for key, or computes it via fn under +// singleflight. When store is false the computed entry is served but not +// cached (used for near-now windows whose data is still being written). The +// returned hit is true only when the entry came from the cache. +func (c *ResultCache) Fetch(key uint64, store bool, fn func() (Entry, error)) (Entry, bool, error) { + if e, ok := c.Get(key); ok { + c.hits.Add(1) + return e, true, nil + } + c.misses.Add(1) + + v, err, _ := c.sf.Do(strconv.FormatUint(key, 10), func() (any, error) { + // Another in-flight caller may have populated the cache while we waited. + if e, ok := c.Get(key); ok { + return e, nil + } + e, err := fn() + if err != nil { + return Entry{}, err + } + if store { + c.Set(key, e) + } + return e, nil + }) + if err != nil { + return Entry{}, false, err + } + return v.(Entry), false, nil +} + +// Stats returns cumulative hit and miss counts. +func (c *ResultCache) Stats() (hits, misses uint64) { + return c.hits.Load(), c.misses.Load() +} + +// Len returns the current number of cached entries. +func (c *ResultCache) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.ll.Len() +} diff --git a/proxy/cache/cache_test.go b/proxy/cache/cache_test.go new file mode 100644 index 0000000..34891c7 --- /dev/null +++ b/proxy/cache/cache_test.go @@ -0,0 +1,155 @@ +package cache + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func entry(s string) Entry { return Entry{Body: []byte(s), ContentType: "application/json"} } + +func TestKeyStability(t *testing.T) { + a := Key("up", 1000, 2000, 15000) + b := Key("up", 1000, 2000, 15000) + if a != b { + t.Fatalf("same inputs produced different keys: %d != %d", a, b) + } + // Any component change must change the key. + for _, k := range []uint64{ + Key("up{}", 1000, 2000, 15000), + Key("up", 1001, 2000, 15000), + Key("up", 1000, 2001, 15000), + Key("up", 1000, 2000, 15001), + } { + if k == a { + t.Fatalf("distinct inputs collided on key %d", k) + } + } +} + +func TestGetSetHit(t *testing.T) { + c := New(4, time.Minute) + k := Key("up", 0, 0, 0) + if _, ok := c.Get(k); ok { + t.Fatal("expected miss on empty cache") + } + c.Set(k, entry("v1")) + got, ok := c.Get(k) + if !ok || string(got.Body) != "v1" { + t.Fatalf("expected hit v1, got ok=%v body=%q", ok, got.Body) + } +} + +func TestTTLExpiry(t *testing.T) { + c := New(4, 30*time.Second) + base := time.Unix(1_000_000, 0) + cur := base + c.now = func() time.Time { return cur } + + k := Key("up", 0, 0, 0) + c.Set(k, entry("v1")) + if _, ok := c.Get(k); !ok { + t.Fatal("expected hit immediately after set") + } + cur = base.Add(31 * time.Second) // past TTL + if _, ok := c.Get(k); ok { + t.Fatal("expected miss after TTL expiry") + } + if c.Len() != 0 { + t.Fatalf("expired entry should have been evicted, len=%d", c.Len()) + } +} + +func TestLRUEviction(t *testing.T) { + c := New(2, time.Minute) + k1, k2, k3 := Key("a", 0, 0, 0), Key("b", 0, 0, 0), Key("c", 0, 0, 0) + c.Set(k1, entry("a")) + c.Set(k2, entry("b")) + // Touch k1 so k2 becomes least-recently-used. + if _, ok := c.Get(k1); !ok { + t.Fatal("k1 should be present") + } + c.Set(k3, entry("c")) // evicts k2 + if _, ok := c.Get(k2); ok { + t.Fatal("k2 should have been evicted as LRU") + } + if _, ok := c.Get(k1); !ok { + t.Fatal("k1 should still be present") + } + if _, ok := c.Get(k3); !ok { + t.Fatal("k3 should be present") + } +} + +func TestFetchSingleflightDedup(t *testing.T) { + c := New(8, time.Minute) + k := Key("slow", 0, 0, 0) + + var produced atomic.Int64 + release := make(chan struct{}) + started := make(chan struct{}, 1) + + const n = 20 + var wg sync.WaitGroup + results := make([]string, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + e, _, err := c.Fetch(k, true, func() (Entry, error) { + produced.Add(1) + select { + case started <- struct{}{}: + default: + } + <-release // hold the flight open so the others pile up + return entry("computed"), nil + }) + if err != nil { + t.Errorf("fetch %d: %v", idx, err) + return + } + results[idx] = string(e.Body) + }(i) + } + + <-started // ensure the first flight is in fn() before releasing + time.Sleep(20 * time.Millisecond) // let the rest coalesce + close(release) + wg.Wait() + + if got := produced.Load(); got != 1 { + t.Fatalf("expected fn to run once under singleflight, ran %d times", got) + } + for i, r := range results { + if r != "computed" { + t.Fatalf("result %d = %q, want computed", i, r) + } + } + // After the flight, the value is cached: next Fetch is a hit. + _, hit, _ := c.Fetch(k, true, func() (Entry, error) { + t.Fatal("fn should not run on a cache hit") + return Entry{}, nil + }) + if !hit { + t.Fatal("expected cache hit after singleflight populated the entry") + } +} + +func TestFetchNoStore(t *testing.T) { + c := New(8, time.Minute) + k := Key("fresh", 0, 0, 0) + var runs atomic.Int64 + produce := func() (Entry, error) { runs.Add(1); return entry("x"), nil } + + if _, hit, _ := c.Fetch(k, false, produce); hit { + t.Fatal("first fetch cannot be a hit") + } + if _, hit, _ := c.Fetch(k, false, produce); hit { + t.Fatal("store=false must never populate the cache") + } + if runs.Load() != 2 { + t.Fatalf("expected 2 computations with store=false, got %d", runs.Load()) + } +} diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index b6eaa73..536894b 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -13,8 +13,9 @@ import ( "github.com/PromClick/PromClick/clickhouse" "github.com/PromClick/PromClick/eval" - "github.com/PromClick/PromClick/proxy/config" + "github.com/PromClick/PromClick/proxy/cache" nativech "github.com/PromClick/PromClick/proxy/clickhouse" + "github.com/PromClick/PromClick/proxy/config" "github.com/PromClick/PromClick/proxy/server" "github.com/PromClick/PromClick/proxy/server/handlers" ) @@ -123,6 +124,17 @@ func main() { Tables: cfg.Schema.Tables, } + // Optional in-memory query-result cache + var resultCache *cache.ResultCache + if cfg.Cache.Enabled { + resultCache = cache.New(cfg.Cache.MaxSize, cfg.Cache.TTL) + logger.Info("query result cache enabled", + "max_size", cfg.Cache.MaxSize, + "ttl", cfg.Cache.TTL, + "max_freshness", cfg.Cache.MaxFreshness, + ) + } + // Create handler h := &handlers.Handler{ Cfg: cfg, @@ -130,6 +142,7 @@ func main() { Evaluator: evaluator, Meta: meta, Pool: queryPool, + Cache: resultCache, } // Create server diff --git a/proxy/go.mod b/proxy/go.mod index efaeff9..8fc2fea 100644 --- a/proxy/go.mod +++ b/proxy/go.mod @@ -5,14 +5,15 @@ go 1.24.1 require ( github.com/ClickHouse/ch-go v0.71.0 github.com/PromClick/PromClick v0.0.0 + github.com/cespare/xxhash/v2 v2.3.0 github.com/golang/snappy v0.0.4 github.com/prometheus/prometheus v0.302.1 + golang.org/x/sync v0.19.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/dmarkham/enumer v1.6.3 // indirect github.com/go-faster/city v1.0.1 // indirect @@ -42,7 +43,6 @@ require ( go.uber.org/zap v1.27.1 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/mod v0.31.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect diff --git a/proxy/server/handlers/labels.go b/proxy/server/handlers/labels.go index f730ff8..a86bb80 100644 --- a/proxy/server/handlers/labels.go +++ b/proxy/server/handlers/labels.go @@ -2,10 +2,37 @@ package handlers import ( "net/http" + "sort" ) -// Labels handles /api/v1/labels. +// Labels handles /api/v1/labels. When match[] selectors are supplied, the label +// names are scoped to the matching series; otherwise all label names are +// returned. start/end are accepted for Prometheus compatibility but not used to +// prune series (the label cache has no time dimension). func (h *Handler) Labels(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + if matchers := r.Form["match[]"]; len(matchers) > 0 { + sets, err := h.matchedSeries(r.Context(), matchers, seriesLimit) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_data", err.Error()) + return + } + nameSet := map[string]struct{}{"__name__": {}} + for _, s := range sets { + for k := range s { + nameSet[k] = struct{}{} + } + } + names := make([]string, 0, len(nameSet)) + for k := range nameSet { + names = append(names, k) + } + sort.Strings(names) + writeJSON(w, APIResponse{Status: "success", Data: names}) + return + } + labels, err := h.Meta.Labels( r.Context(), h.PromCfg.Schema.TimeSeriesTable, @@ -21,13 +48,37 @@ func (h *Handler) Labels(w http.ResponseWriter, r *http.Request) { }) } -// LabelValues handles /api/v1/label/{name}/values. +// LabelValues handles /api/v1/label/{name}/values. When match[] selectors are +// supplied, values are scoped to the matching series; otherwise all values for +// the label are returned. start/end are accepted but not used to prune series. func (h *Handler) LabelValues(w http.ResponseWriter, r *http.Request) { labelName := r.PathValue("name") if labelName == "" { writeError(w, http.StatusBadRequest, "bad_data", "missing label name") return } + _ = r.ParseForm() + + if matchers := r.Form["match[]"]; len(matchers) > 0 { + sets, err := h.matchedSeries(r.Context(), matchers, seriesLimit) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_data", err.Error()) + return + } + valueSet := make(map[string]struct{}) + for _, s := range sets { + if v, ok := s[labelName]; ok && v != "" { + valueSet[v] = struct{}{} + } + } + values := make([]string, 0, len(valueSet)) + for v := range valueSet { + values = append(values, v) + } + sort.Strings(values) + writeJSON(w, APIResponse{Status: "success", Data: values}) + return + } values, err := h.Meta.LabelValues( r.Context(), diff --git a/proxy/server/handlers/matchers.go b/proxy/server/handlers/matchers.go new file mode 100644 index 0000000..4242eab --- /dev/null +++ b/proxy/server/handlers/matchers.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/promql/parser" + + nativech "github.com/PromClick/PromClick/proxy/clickhouse" +) + +// parsedSelector is one match[] selector split into its metric name and the +// remaining label matchers. +type parsedSelector struct { + // MetricName is the value of the __name__ matcher when it is an equality + // match, else "" (e.g. a regex name matcher or a bare label selector). + MetricName string + // Matchers holds every matcher except a leading __name__= equality, mapped + // to the native label-cache matcher form. + Matchers []nativech.LabelMatcher + // All is the raw matcher set (including __name__), for the ClickHouse + // fallback path. + All []*labels.Matcher +} + +// matchTypeOp maps a Prometheus match type to the label-cache operator string. +func matchTypeOp(t labels.MatchType) string { + switch t { + case labels.MatchNotEqual: + return "!=" + case labels.MatchRegexp: + return "=~" + case labels.MatchNotRegexp: + return "!~" + default: + return "=" + } +} + +// parseSelector parses a single match[] expression such as `up{job="api"}` or +// `{__name__=~"node_.*"}` into a metric name and label matchers, using the +// Prometheus parser (the same one the translator uses). +func parseSelector(expr string) (parsedSelector, error) { + ms, err := parser.ParseMetricSelector(expr) + if err != nil { + return parsedSelector{}, err + } + ps := parsedSelector{All: ms} + for _, m := range ms { + if m.Name == labels.MetricName && m.Type == labels.MatchEqual { + ps.MetricName = m.Value + continue + } + ps.Matchers = append(ps.Matchers, nativech.LabelMatcher{ + Name: m.Name, + Op: matchTypeOp(m.Type), + Value: m.Value, + }) + } + return ps, nil +} diff --git a/proxy/server/handlers/meta.go b/proxy/server/handlers/meta.go index 641ba0e..ca1ae4c 100644 --- a/proxy/server/handlers/meta.go +++ b/proxy/server/handlers/meta.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "strings" + + "github.com/prometheus/prometheus/model/labels" ) // MetaQuerier performs metadata queries against ClickHouse HTTP interface. @@ -176,6 +178,121 @@ func (m *MetaQuerier) LabelValues(ctx context.Context, labelName, tagsTable, met return result, nil } +// colMatchCond renders a single matcher as a ClickHouse boolean condition on +// the given column expression. Regex matchers are anchored to match Prometheus +// semantics. Values are escaped for inline SQL. +func colMatchCond(col string, mt labels.MatchType, value string) string { + v := chEscape(value) + switch mt { + case labels.MatchNotEqual: + return fmt.Sprintf("%s != '%s'", col, v) + case labels.MatchRegexp: + return fmt.Sprintf("match(%s, '^(?:%s)$')", col, v) + case labels.MatchNotRegexp: + return fmt.Sprintf("NOT match(%s, '^(?:%s)$')", col, v) + default: // MatchEqual + return fmt.Sprintf("%s = '%s'", col, v) + } +} + +// chMatcherWhere builds a WHERE clause (without the leading WHERE) applying +// every matcher against the Prometheus-mode time_series schema: __name__ maps +// to the metric-name column, other labels to JSONExtractString(labels, name). +func chMatcherWhere(all []*labels.Matcher, metricNameCol, labelsCol string) string { + if len(all) == 0 { + return "1" + } + conds := make([]string, 0, len(all)) + for _, mt := range all { + col := fmt.Sprintf("JSONExtractString(%s, '%s')", labelsCol, chEscape(mt.Name)) + if mt.Name == labels.MetricName { + col = metricNameCol + } + conds = append(conds, colMatchCond(col, mt.Type, mt.Value)) + } + return strings.Join(conds, " AND ") +} + +// otelMatcherWhere builds a WHERE clause for OTel mode: __name__ maps to the +// sanitised MetricName, other labels to the merged attribute map. +func otelMatcherWhere(all []*labels.Matcher) string { + if len(all) == 0 { + return "1" + } + conds := make([]string, 0, len(all)) + for _, mt := range all { + col := "(" + otelLabelsExpr + ")['" + chEscape(mt.Name) + "']" + if mt.Name == labels.MetricName { + col = otelSanitize("MetricName") + } + conds = append(conds, colMatchCond(col, mt.Type, mt.Value)) + } + return strings.Join(conds, " AND ") +} + +// SeriesMatch queries ClickHouse for the label sets matching a single selector, +// applying every matcher. Used as the fallback when the label cache cannot +// answer (cache disabled/unloaded, or a regex/negated metric-name matcher). +func (m *MetaQuerier) SeriesMatch(ctx context.Context, sel parsedSelector, tagsTable, metricNameCol, labelsCol string, limit int) ([]map[string]string, error) { + var sql string + if m.Mode == "otel" { + where := otelMatcherWhere(sel.All) + sql = "SELECT DISTINCT " + otelSanitize("MetricName") + " AS metric_name, " + + otelLabelsExpr + " AS labels FROM " + + m.otelUnion("MetricName, ResourceAttributes, Attributes", where) + + fmt.Sprintf(" LIMIT %d", limit) + } else { + where := chMatcherWhere(sel.All, metricNameCol, labelsCol) + sql = fmt.Sprintf( + "SELECT %s AS metric_name, %s AS labels FROM %s WHERE %s ORDER BY unix_milli DESC LIMIT 1 BY fingerprint LIMIT %d", + metricNameCol, labelsCol, tagsTable, where, limit, + ) + } + + rows, err := m.query(ctx, sql) + if err != nil { + return nil, err + } + + results := make([]map[string]string, 0, len(rows)) + for _, row := range rows { + var v struct { + MetricName string `json:"metric_name"` + Labels json.RawMessage `json:"labels"` + } + if err := json.Unmarshal(row, &v); err != nil { + continue + } + lbls := decodeLabels(v.Labels) + labelSet := make(map[string]string, len(lbls)+1) + labelSet["__name__"] = v.MetricName + for k, val := range lbls { + labelSet[k] = val + } + results = append(results, labelSet) + } + return results, nil +} + +// decodeLabels decodes the JSONEachRow "labels" column, which is either a JSON +// object (Map columns, OTel mode) or a JSON-encoded string containing an object +// (the default String labels column). Returns an empty map on anything else. +func decodeLabels(raw json.RawMessage) map[string]string { + out := map[string]string{} + if len(raw) == 0 { + return out + } + if raw[0] == '"' { + var s string + if json.Unmarshal(raw, &s) == nil && s != "" { + _ = json.Unmarshal([]byte(s), &out) + } + return out + } + _ = json.Unmarshal(raw, &out) + return out +} + // parseCount extracts a count value from CH JSON row (count() returns string in JSONEachRow). func parseCount(row []byte) float64 { var v struct { diff --git a/proxy/server/handlers/metadata.go b/proxy/server/handlers/metadata.go index 1bbdc5e..567131c 100644 --- a/proxy/server/handlers/metadata.go +++ b/proxy/server/handlers/metadata.go @@ -2,12 +2,64 @@ package handlers import ( "net/http" + "strconv" ) -// Metadata handles /api/v1/metadata. Returns an empty object stub. +// metricMetadata is one entry of the Prometheus /api/v1/metadata response. +// PromClick does not record HELP/TYPE/UNIT (remote_write does not carry it in +// the sample stream), so type is reported as "unknown" — enough to populate +// Grafana's metric browser. +type metricMetadata struct { + Type string `json:"type"` + Help string `json:"help"` + Unit string `json:"unit"` +} + +// Metadata handles /api/v1/metadata. It returns one "unknown"-typed entry per +// metric name so Grafana's metric browser and autocomplete are populated. +// Honours the optional `metric` and `limit` query parameters. func (h *Handler) Metadata(w http.ResponseWriter, r *http.Request) { - writeJSON(w, APIResponse{ - Status: "success", - Data: map[string]interface{}{}, - }) + _ = r.ParseForm() + + metric := formOrQuery(r, "metric") + limit := -1 + if s := formOrQuery(r, "limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + limit = n + } + } + + out := make(map[string][]metricMetadata) + entry := []metricMetadata{{Type: "unknown", Help: "", Unit: ""}} + + if metric != "" { + out[metric] = entry + writeJSON(w, APIResponse{Status: "success", Data: out}) + return + } + + if limit == 0 { + writeJSON(w, APIResponse{Status: "success", Data: out}) + return + } + + names, err := h.Meta.LabelValues( + r.Context(), + "__name__", + h.PromCfg.Schema.TimeSeriesTable, + h.PromCfg.Schema.Columns.MetricName, + h.PromCfg.Schema.Columns.Labels, + ) + if err != nil { + // Degrade gracefully: an empty (but well-formed) metadata map. + writeJSON(w, APIResponse{Status: "success", Data: out}) + return + } + for _, n := range names { + if limit > 0 && len(out) >= limit { + break + } + out[n] = entry + } + writeJSON(w, APIResponse{Status: "success", Data: out}) } diff --git a/proxy/server/handlers/parity_test.go b/proxy/server/handlers/parity_test.go new file mode 100644 index 0000000..ff6612e --- /dev/null +++ b/proxy/server/handlers/parity_test.go @@ -0,0 +1,273 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "sort" + "strings" + "testing" + + promcfg "github.com/PromClick/PromClick/config" + proxycfg "github.com/PromClick/PromClick/proxy/config" +) + +// fakeCH returns an httptest server that mimics the ClickHouse HTTP interface +// for the metadata/series query paths. It routes on SQL content and emits +// JSONEachRow rows. The `labels` column is emitted as a JSON-encoded string, +// exactly as a ClickHouse String column is serialised. +func fakeCH(t *testing.T) *httptest.Server { + t.Helper() + series := []struct { + name string + labels map[string]string + }{ + {"up", map[string]string{"job": "api", "instance": "h1"}}, + {"up", map[string]string{"job": "api", "instance": "h2"}}, + {"up", map[string]string{"job": "db", "instance": "h3"}}, + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + sql := string(body) + enc := json.NewEncoder(w) + + switch { + // SeriesMatch: SELECT metric_name AS metric_name, labels AS labels ... + case strings.Contains(sql, "AS labels"): + for _, s := range series { + if !sqlMatches(sql, s.name, s.labels) { + continue + } + // Emit labels as a JSON string, like a CH String column. + lb, _ := json.Marshal(s.labels) + _ = enc.Encode(map[string]string{ + "metric_name": s.name, + "labels": string(lb), + }) + } + // LabelValues(__name__): SELECT DISTINCT metric_name AS value ... + case strings.Contains(sql, "AS value"): + for _, v := range []string{"up", "process_cpu"} { + _ = enc.Encode(map[string]string{"value": v}) + } + default: + // empty result + } + })) +} + +var ( + reNameEq = regexp.MustCompile(`metric_name = '([^']*)'`) + reLabelEq = regexp.MustCompile(`JSONExtractString\(labels, '(\w+)'\) = '([^']*)'`) +) + +// sqlMatches applies the equality conditions found in the SQL (the WHERE that +// chMatcherWhere built) to one series, so the fake CH filters like the real one. +func sqlMatches(sql, name string, lbls map[string]string) bool { + if m := reNameEq.FindStringSubmatch(sql); m != nil && m[1] != name { + return false + } + for _, m := range reLabelEq.FindAllStringSubmatch(sql, -1) { + if lbls[m[1]] != m[2] { + return false + } + } + return true +} + +func newTestHandler(chURL string) *Handler { + return &Handler{ + Cfg: &proxycfg.Config{}, + PromCfg: &promcfg.Config{ + Schema: promcfg.SchemaConfig{ + SamplesTable: "samples", + TimeSeriesTable: "time_series", + Columns: promcfg.ColumnConfig{ + MetricName: "metric_name", + Timestamp: "unix_milli", + Value: "value", + Labels: "labels", + }, + }, + }, + Meta: &MetaQuerier{ + Addr: chURL, + Database: "metrics", + HTTPClient: http.DefaultClient, + }, + // Pool nil → matchedSeries takes the ClickHouse fallback path. + } +} + +func decodeAPI(t *testing.T, body []byte) map[string]json.RawMessage { + t.Helper() + var resp struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("bad JSON envelope: %v (%s)", err, body) + } + if resp.Status != "success" { + t.Fatalf("status = %q, want success (%s)", resp.Status, body) + } + return map[string]json.RawMessage{"data": resp.Data} +} + +func TestSeriesAppliesLabelMatchers(t *testing.T) { + srv := fakeCH(t) + defer srv.Close() + h := newTestHandler(srv.URL) + + req := httptest.NewRequest(http.MethodGet, + "/api/v1/series?match[]="+url.QueryEscape(`up{job="api"}`), nil) + w := httptest.NewRecorder() + h.Series(w, req) + + data := decodeAPI(t, w.Body.Bytes()) + var sets []map[string]string + if err := json.Unmarshal(data["data"], &sets); err != nil { + t.Fatalf("decode series: %v", err) + } + // Fake CH returns 3 series; the job="api" matcher must drop the db one. + if len(sets) != 2 { + t.Fatalf("got %d series, want 2 (job=api only): %+v", len(sets), sets) + } + for _, s := range sets { + if s["job"] != "api" { + t.Fatalf("series with job=%q leaked past matcher", s["job"]) + } + if s["__name__"] != "up" { + t.Fatalf("missing/incorrect __name__: %+v", s) + } + } +} + +func TestLabelValuesScopedByMatch(t *testing.T) { + srv := fakeCH(t) + defer srv.Close() + h := newTestHandler(srv.URL) + + req := httptest.NewRequest(http.MethodGet, + "/api/v1/label/instance/values?match[]="+url.QueryEscape(`up{job="api"}`), nil) + req.SetPathValue("name", "instance") + w := httptest.NewRecorder() + h.LabelValues(w, req) + + data := decodeAPI(t, w.Body.Bytes()) + var values []string + if err := json.Unmarshal(data["data"], &values); err != nil { + t.Fatalf("decode values: %v", err) + } + sort.Strings(values) + want := []string{"h1", "h2"} // h3 is job=db, excluded + if strings.Join(values, ",") != strings.Join(want, ",") { + t.Fatalf("instance values = %v, want %v", values, want) + } +} + +func TestLabelsScopedByMatchIncludesName(t *testing.T) { + srv := fakeCH(t) + defer srv.Close() + h := newTestHandler(srv.URL) + + req := httptest.NewRequest(http.MethodGet, + "/api/v1/labels?match[]="+url.QueryEscape(`up{job="api"}`), nil) + w := httptest.NewRecorder() + h.Labels(w, req) + + data := decodeAPI(t, w.Body.Bytes()) + var names []string + if err := json.Unmarshal(data["data"], &names); err != nil { + t.Fatalf("decode labels: %v", err) + } + got := strings.Join(sortedCopy(names), ",") + want := "__name__,instance,job" + if got != want { + t.Fatalf("label names = %q, want %q", got, want) + } +} + +func TestMetadataPopulated(t *testing.T) { + srv := fakeCH(t) + defer srv.Close() + h := newTestHandler(srv.URL) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/metadata", nil) + w := httptest.NewRecorder() + h.Metadata(w, req) + + data := decodeAPI(t, w.Body.Bytes()) + var md map[string][]metricMetadata + if err := json.Unmarshal(data["data"], &md); err != nil { + t.Fatalf("decode metadata: %v", err) + } + if len(md) == 0 { + t.Fatal("metadata must not be empty") + } + if _, ok := md["up"]; !ok { + t.Fatalf("expected metric 'up' in metadata, got keys %v", keysOf(md)) + } +} + +func TestMetadataMetricFilter(t *testing.T) { + srv := fakeCH(t) + defer srv.Close() + h := newTestHandler(srv.URL) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/metadata?metric=foo", nil) + w := httptest.NewRecorder() + h.Metadata(w, req) + + data := decodeAPI(t, w.Body.Bytes()) + var md map[string][]metricMetadata + _ = json.Unmarshal(data["data"], &md) + if len(md) != 1 || len(md["foo"]) != 1 { + t.Fatalf("metric filter should return exactly {foo:[...]}, got %v", md) + } +} + +func TestChMatcherWhere(t *testing.T) { + sel, err := parseSelector(`up{job="api",env!="prod",instance=~"h.*",zone!~"eu.*"}`) + if err != nil { + t.Fatalf("parse: %v", err) + } + where := chMatcherWhere(sel.All, "metric_name", "labels") + for _, want := range []string{ + "metric_name = 'up'", + "JSONExtractString(labels, 'job') = 'api'", + "JSONExtractString(labels, 'env') != 'prod'", + "match(JSONExtractString(labels, 'instance'), '^(?:h.*)$')", + "NOT match(JSONExtractString(labels, 'zone'), '^(?:eu.*)$')", + } { + if !strings.Contains(where, want) { + t.Fatalf("WHERE missing %q\n got: %s", want, where) + } + } +} + +func TestChEscapeInMatcherValue(t *testing.T) { + // A value containing a quote must not break out of the SQL literal. + sel, _ := parseSelector(`up{job="a'b"}`) + where := chMatcherWhere(sel.All, "metric_name", "labels") + if !strings.Contains(where, `'a\'b'`) { + t.Fatalf("quote not escaped in WHERE: %s", where) + } +} + +func sortedCopy(s []string) []string { + c := append([]string(nil), s...) + sort.Strings(c) + return c +} + +func keysOf(m map[string][]metricMetadata) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/proxy/server/handlers/query.go b/proxy/server/handlers/query.go index 1610e8f..e1ceb2c 100644 --- a/proxy/server/handlers/query.go +++ b/proxy/server/handlers/query.go @@ -17,8 +17,9 @@ import ( "github.com/PromClick/PromClick/translator" "github.com/PromClick/PromClick/types" - proxycfg "github.com/PromClick/PromClick/proxy/config" + "github.com/PromClick/PromClick/proxy/cache" nativech "github.com/PromClick/PromClick/proxy/clickhouse" + proxycfg "github.com/PromClick/PromClick/proxy/config" ) // Handler holds shared dependencies for query handlers. @@ -28,7 +29,8 @@ type Handler struct { Evaluator *eval.Evaluator Meta *MetaQuerier Writer *nativech.Writer - Pool *nativech.Pool // native TCP pool for downsampled queries + Pool *nativech.Pool // native TCP pool for downsampled queries + Cache *cache.ResultCache // optional in-memory query-result cache (nil = disabled) } // Query handles /api/v1/query (instant query). @@ -61,32 +63,52 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) { } slog.Debug("transpile", "duration", time.Since(t0), "query", query) - // Label cache fast-path for instant queries - if result, ok := h.tryCacheOnlyAgg(plan, evalTime, evalTime, 0, t0); ok { - writeJSON(w, APIResponse{Status: "success", Data: formatResult(result)}) + // Cache path: only cache instant queries with an explicit, sufficiently old + // evaluation time — an implicit "now" is a moving target and its data may + // still be arriving. + if h.Cache != nil { + now := time.Now() + key := cache.Key(query, evalTime.UnixMilli(), evalTime.UnixMilli(), 0) + store := evalTime.Before(now.Add(-h.Cfg.Cache.MaxFreshness)) + entry, hit, err := h.Cache.Fetch(key, store, func() (cache.Entry, error) { + result, _, err := h.computeInstant(r, plan, evalTime, t0) + if err != nil { + return cache.Entry{}, err + } + b, ct := renderResultBytes(result) + return cache.Entry{Body: b, ContentType: ct}, nil + }) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "execution", fmt.Sprintf("%v", err)) + return + } + slog.Info("query", "query", query, "path", cachePath(hit), "total", time.Since(t0)) + w.Header().Set("Content-Type", entry.ContentType) + _, _ = w.Write(entry.Body) return } - t1 := time.Now() - result, err := h.Evaluator.EvalPlan(r.Context(), plan, evalTime, evalTime, 0) + result, fast, err := h.computeInstant(r, plan, evalTime, t0) if err != nil { writeError(w, http.StatusUnprocessableEntity, "execution", fmt.Sprintf("%v", err)) return } + if !fast { + series := resultSeriesCount(result) + slog.Info("query", "query", query, "series", series, "total", time.Since(t0)) + } + writeResult(w, result) +} - series := resultSeriesCount(result) - slog.Debug("eval", "duration", time.Since(t1), "series", series) - - slog.Info("query", - "query", query, - "series", series, - "total", time.Since(t0), - ) - - writeJSON(w, APIResponse{ - Status: "success", - Data: formatResult(result), - }) +// computeInstant runs the label-cache fast path then the general evaluator for +// an instant query. The returned bool is true when a fast path produced the +// result (which logs its own line). +func (h *Handler) computeInstant(r *http.Request, plan *translator.SQLPlan, evalTime, t0 time.Time) (*types.QueryResult, bool, error) { + if result, ok := h.tryCacheOnlyAgg(plan, evalTime, evalTime, 0, t0); ok { + return result, true, nil + } + result, err := h.Evaluator.EvalPlan(r.Context(), plan, evalTime, evalTime, 0) + return result, false, err } // QueryRange handles /api/v1/query_range (range query). @@ -145,45 +167,82 @@ func (h *Handler) QueryRange(w http.ResponseWriter, r *http.Request) { } slog.Debug("transpile", "duration", time.Since(t0), "query", query) + // Cache path: cache the whole rendered response keyed by (query,start,end, + // step). Skip *storing* windows whose end touches "now" (max_freshness) — + // that data is still being written — but still serve them via singleflight. + if h.Cache != nil { + now := time.Now() + key := cache.Key(query, start.UnixMilli(), end.UnixMilli(), step.Milliseconds()) + store := !end.After(now.Add(-h.Cfg.Cache.MaxFreshness)) + entry, hit, err := h.Cache.Fetch(key, store, func() (cache.Entry, error) { + result, _, err := h.computeRange(r, plan, start, end, step, t0) + if err != nil { + return cache.Entry{}, err + } + b, ct := renderResultBytes(result) + return cache.Entry{Body: b, ContentType: ct}, nil + }) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "execution", fmt.Sprintf("%v", err)) + return + } + slog.Info("query_range", "query", query, "range", end.Sub(start).String(), + "step", step.String(), "path", cachePath(hit), "total", time.Since(t0)) + w.Header().Set("Content-Type", entry.ContentType) + _, _ = w.Write(entry.Body) + return + } + + result, fast, err := h.computeRange(r, plan, start, end, step, t0) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "execution", fmt.Sprintf("%v", err)) + return + } + if !fast { + series := resultSeriesCount(result) + slog.Info("query_range", + "query", query, + "range", end.Sub(start).String(), + "step", step.String(), + "series", series, + "total", time.Since(t0), + ) + } + + writeResult(w, result) +} + +// computeRange runs the label-cache and downsampling fast paths then the +// general evaluator for a range query. The returned bool is true when a fast +// path produced the result (which logs its own line). +func (h *Handler) computeRange(r *http.Request, plan *translator.SQLPlan, start, end time.Time, step time.Duration, t0 time.Time) (*types.QueryResult, bool, error) { // Label cache fast-path: count/group aggregations on plain selectors // can be answered from cached labels without fetching samples from CH. if result, ok := h.tryCacheOnlyAgg(plan, start, end, step, t0); ok { - writeResult(w, result) - return + return result, true, nil } // Downsampling fast-path: try tier query for supported functions if h.Pool != nil && h.Cfg.Downsampling.Enabled && plan.FuncName != "" && plan.MetricName != "" { if plan.FuncName == "histogram_quantile" { if result, ok := h.tryDownsampledHistogram(r, plan, start, end, step, t0); ok { - writeResult(w, result) - return + return result, true, nil } } else if result, ok := h.tryDownsampledQuery(r, plan, start, end, step, t0); ok { - writeResult(w, result) - return + return result, true, nil } } - t1 := time.Now() result, err := h.Evaluator.EvalPlan(r.Context(), plan, start, end, step) - if err != nil { - writeError(w, http.StatusUnprocessableEntity, "execution", fmt.Sprintf("%v", err)) - return - } - - series := resultSeriesCount(result) - slog.Debug("eval", "duration", time.Since(t1), "series", series) - - slog.Info("query_range", - "query", query, - "range", end.Sub(start).String(), - "step", step.String(), - "series", series, - "total", time.Since(t0), - ) + return result, false, err +} - writeResult(w, result) +// cachePath maps a cache hit/miss to the slog "path" field value. +func cachePath(hit bool) string { + if hit { + return "cache-hit" + } + return "cache-miss" } // writeResult writes a query result as a Prometheus API JSON response. @@ -1004,7 +1063,7 @@ func computeHistogramQuantile(phi float64, matrix types.Matrix, step time.Durati // Index: series le label → value per timestamp seriesByLE := make(map[string]map[int64]float64) // le_string → ts → value - leValues := make(map[string]float64) // le_string → le_float + leValues := make(map[string]float64) // le_string → le_float for _, s := range matrix { leStr, ok := s.Labels["le"] if !ok { @@ -1058,7 +1117,7 @@ func computeHistogramQuantile(phi float64, matrix types.Matrix, step time.Durati // applyMathFunc applies a per-value math transformation to all samples in the matrix. func applyMathFunc(matrix types.Matrix, fn string, plan *translator.SQLPlan) types.Matrix { // Extract params for clamp functions - clampMin := plan.AggParam // clamp_min param, or clamp min + clampMin := plan.AggParam // clamp_min param, or clamp min clampMax := plan.SmoothingTF // clamp max (stored in SmoothingTF) if fn == "clamp_max" { clampMax = plan.AggParam diff --git a/proxy/server/handlers/response.go b/proxy/server/handlers/response.go index 7b6939e..a03d82b 100644 --- a/proxy/server/handlers/response.go +++ b/proxy/server/handlers/response.go @@ -2,6 +2,7 @@ package handlers import ( "bufio" + "bytes" "encoding/json" "fmt" "log/slog" @@ -30,13 +31,10 @@ func writeJSON(w http.ResponseWriter, v interface{}) { } } -// writeMatrixResponse streams a matrix result directly to the writer, -// avoiding intermediate []interface{} allocations for large result sets. -func writeMatrixResponse(w http.ResponseWriter, matrix types.Matrix) { - w.Header().Set("Content-Type", "application/json") - - bw := bufio.NewWriterSize(w, 64*1024) - +// encodeMatrix writes the full Prometheus matrix response envelope into bw, +// avoiding intermediate []interface{} allocations for large result sets. It is +// shared by the streaming writer and the byte builder used by the cache. +func encodeMatrix(bw *bufio.Writer, matrix types.Matrix) { bw.WriteString(`{"status":"success","data":{"resultType":"matrix","result":[`) for si, s := range matrix { @@ -65,11 +63,46 @@ func writeMatrixResponse(w http.ResponseWriter, matrix types.Matrix) { bw.WriteString(`]}}`) bw.WriteByte('\n') +} + +// writeMatrixResponse streams a matrix result directly to the writer, +// avoiding holding the full response in memory for large result sets. +func writeMatrixResponse(w http.ResponseWriter, matrix types.Matrix) { + w.Header().Set("Content-Type", "application/json") + + bw := bufio.NewWriterSize(w, 64*1024) + encodeMatrix(bw, matrix) if err := bw.Flush(); err != nil { slog.Error("writeMatrixResponse: flush failed", "error", err) } } +// matrixResponseBytes renders a matrix response into a byte slice using the +// same encoding as writeMatrixResponse. +func matrixResponseBytes(matrix types.Matrix) []byte { + var buf bytes.Buffer + buf.Grow(64 * 1024) + bw := bufio.NewWriter(&buf) + encodeMatrix(bw, matrix) + _ = bw.Flush() + return buf.Bytes() +} + +// renderResultBytes serialises a query result into Prometheus API JSON bytes +// and its content type, so the same bytes can be both cached and written to the +// client. Mirrors writeResult's matrix/other split. +func renderResultBytes(qr *types.QueryResult) ([]byte, string) { + if qr.Type == "matrix" && len(qr.Matrix) > 0 { + return matrixResponseBytes(qr.Matrix), "application/json" + } + b, err := json.Marshal(APIResponse{Status: "success", Data: formatResult(qr)}) + if err != nil { + slog.Error("renderResultBytes: marshal failed", "error", err) + } + b = append(b, '\n') + return b, "application/json" +} + func writeError(w http.ResponseWriter, code int, errType string, err interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/proxy/server/handlers/series.go b/proxy/server/handlers/series.go index 1e8f609..8bb74a5 100644 --- a/proxy/server/handlers/series.go +++ b/proxy/server/handlers/series.go @@ -2,11 +2,16 @@ package handlers import ( "context" - "encoding/json" - "fmt" + "log/slog" "net/http" + + "github.com/PromClick/PromClick/fingerprint" ) +// seriesLimit bounds the number of series returned by /api/v1/series and the +// number scanned to answer scoped /labels and /label/{name}/values. +const seriesLimit = 10000 + // Series handles /api/v1/series — returns label sets matching match[] selectors. func (h *Handler) Series(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() @@ -17,92 +22,82 @@ func (h *Handler) Series(w http.ResponseWriter, r *http.Request) { return } - // For simplicity, extract metric name from first matcher and return label sets from CH - // match[] typically looks like: {__name__="metric"} or metric_name - results, err := h.Meta.Series(r.Context(), matchers, - h.PromCfg.Schema.TimeSeriesTable, - h.PromCfg.Schema.Columns.MetricName, - h.PromCfg.Schema.Columns.Labels, - ) + sets, err := h.matchedSeries(r.Context(), matchers, seriesLimit) if err != nil { - writeJSON(w, APIResponse{Status: "success", Data: []interface{}{}}) + // Prometheus returns 200 with an empty result for unmatched/parse-empty + // selectors; surface parse errors as bad_data. + writeError(w, http.StatusBadRequest, "bad_data", err.Error()) return } - - writeJSON(w, APIResponse{Status: "success", Data: results}) + if sets == nil { + sets = []map[string]string{} + } + writeJSON(w, APIResponse{Status: "success", Data: sets}) } -// Series on MetaQuerier — query CH for label sets matching a metric. -func (m *MetaQuerier) Series(ctx context.Context, matchers []string, tagsTable, metricNameCol, labelsCol string) ([]map[string]string, error) { - // Extract metric name from matcher like "{__name__=\"up\"}" or "up" or "{__name__=~\"node_.*\"}" - metricName := "" - for _, match := range matchers { - // Try simple metric name (no braces) - if len(match) > 0 && match[0] != '{' { - metricName = match - break - } - // Try {__name__="value"} pattern - // Simple regex-free extraction - for i := 0; i < len(match)-1; i++ { - if match[i] == '"' { - end := i + 1 - for end < len(match) && match[end] != '"' { - end++ - } - if end < len(match) { - metricName = match[i+1 : end] - break - } - } - } - if metricName != "" { - break - } - } +// matchedSeries resolves the distinct label sets (each including __name__) +// matching any of the given match[] selectors, applying every label matcher. +// It prefers the in-memory label cache when a selector carries a concrete +// metric name, falling back to a scoped ClickHouse query otherwise. +func (h *Handler) matchedSeries(ctx context.Context, rawMatchers []string, limit int) ([]map[string]string, error) { + var out []map[string]string + seen := make(map[uint64]struct{}) - if metricName == "" { - return nil, fmt.Errorf("no metric name found in matchers") + add := func(set map[string]string) bool { + fp := fingerprint.Compute(set) + if _, dup := seen[fp]; dup { + return true + } + seen[fp] = struct{}{} + out = append(out, set) + return len(out) < limit } - var sql string - if m.Mode == "otel" { - // Read distinct label sets for the metric straight from the OTel tables, - // matching raw or sanitised metric name (see renderOTel). - mn := chEscape(metricName) - where := fmt.Sprintf("(MetricName = '%s' OR %s = '%s')", mn, otelSanitize("MetricName"), mn) - // Emit the Map directly (not toJSONString) so JSONEachRow serialises it - // as a JSON object that decodes straight into map[string]string. - sql = "SELECT DISTINCT " + otelSanitize("MetricName") + " AS metric_name, " + - otelLabelsExpr + " AS labels FROM " + - m.otelUnion("MetricName, ResourceAttributes, Attributes", where) + " LIMIT 500" - } else { - sql = fmt.Sprintf( - "SELECT %s AS metric_name, %s AS labels FROM %s WHERE %s = '%s' ORDER BY unix_milli DESC LIMIT 1 BY fingerprint LIMIT 100", - metricNameCol, labelsCol, tagsTable, metricNameCol, metricName, - ) - } + useCache := h.Pool != nil && h.Pool.LabelCache != nil && h.Pool.LabelCache.IsLoaded() - rows, err := m.query(ctx, sql) - if err != nil { - return nil, err - } + for _, raw := range rawMatchers { + sel, err := parseSelector(raw) + if err != nil { + return nil, err + } - var results []map[string]string - for _, row := range rows { - var v struct { - MetricName string `json:"metric_name"` - Labels map[string]string `json:"labels"` + // Cache fast-path: needs a concrete metric name (regex/negated name + // matchers have no metric index to scan). + if useCache && sel.MetricName != "" { + if fps, ok := h.Pool.LabelCache.GetFingerprints(sel.MetricName, sel.Matchers); ok { + for _, fp := range fps { + lbls, hit := h.Pool.LabelCache.GetLabels(fp) + if !hit { + continue + } + set := make(map[string]string, len(lbls)+1) + set["__name__"] = sel.MetricName + for k, v := range lbls { + set[k] = v + } + if !add(set) { + return out, nil + } + } + continue + } } - if err := json.Unmarshal(row, &v); err != nil { - continue + + // ClickHouse fallback: apply all matchers in SQL. + sets, err := h.Meta.SeriesMatch(ctx, sel, + h.PromCfg.Schema.TimeSeriesTable, + h.PromCfg.Schema.Columns.MetricName, + h.PromCfg.Schema.Columns.Labels, + limit) + if err != nil { + slog.Warn("series: clickhouse fallback failed", "error", err) + return nil, nil } - labelSet := make(map[string]string) - labelSet["__name__"] = v.MetricName - for k, val := range v.Labels { - labelSet[k] = val + for _, set := range sets { + if !add(set) { + return out, nil + } } - results = append(results, labelSet) } - return results, nil + return out, nil } diff --git a/proxy/server/server.go b/proxy/server/server.go index d4854dd..7f36ed9 100644 --- a/proxy/server/server.go +++ b/proxy/server/server.go @@ -52,6 +52,7 @@ func (s *Server) Routes() http.Handler { // Label values mux.HandleFunc("GET /api/v1/label/{name}/values", s.handler.LabelValues) + mux.HandleFunc("POST /api/v1/label/{name}/values", s.handler.LabelValues) // Series mux.HandleFunc("GET /api/v1/series", s.handler.Series)