Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
78 changes: 53 additions & 25 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,93 @@ 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:
release-charts:
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."
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions charts/promclick-chart/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*"

Expand Down
9 changes: 9 additions & 0 deletions deploy/proxy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*"

Expand Down
173 changes: 173 additions & 0 deletions proxy/cache/cache.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading