Skip to content
Open
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
13 changes: 13 additions & 0 deletions .chloggen/backend-scheduler-jobs-pending.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
change_type: enhancement
component: backend-scheduler
note: add `tempo_backend_scheduler_jobs_pending`, widen the job-duration histogram buckets, and complete native-histogram coverage.
issues: []
subtext: |
`tempo_backend_scheduler_jobs_pending{tenant, job_type}` reports queue depth: jobs enqueued and not yet dispatched to a worker. Nothing exposed this before, so a submission that fanned out hundreds of jobs showed only the handful already running. `jobs_active` counts work already handed out, so it is bounded by the worker count and cannot indicate that more capacity is needed — only depth can, which makes this the signal to autoscale backend-workers on.

It matters most for redaction. Compaction is gated for a tenant being redacted, so no compaction jobs are created for it, yet the outstanding-blocks metric deliberately keeps reporting that backlog (see `newBlockSelectorForMeasurement`) — an autoscaler on that signal is holding scale for work it cannot dispatch. Redaction depth is the direct input, and the only one that tracks progress.

The `backend_scheduler_job_duration_seconds` buckets move from the client library defaults to powers of two between 10ms and ~11m. The defaults stop at 10s and place no boundary between 2.5s and 5s, where roughly 60% of redaction jobs land, so every percentile drawn from them interpolated inside a single bucket. Dashboards using `histogram_quantile` are unaffected; queries pinned to specific `le` values need updating.

`tempodb_cache_store_size_bytes` was the only histogram in the tree without native-histogram configuration; it now matches the other 33.
user: zalegrala
9 changes: 9 additions & 0 deletions .chloggen/backendwork-dashboard-redaction-progress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
change_type: enhancement
component: operations
note: "tempo-mixin: add a pending-redaction-jobs panel and native-histogram queries to the Backend Work dashboard."
issues: []
subtext: |
"Pending Redaction Jobs" plots `tempo_backend_scheduler_jobs_pending` per tenant, so the dashboard shows how much of a redaction is left. "Active Redaction Jobs" only shows what a worker is running right now, which is bounded by the worker count and reads the same on the first block as on the last.

Both job-duration panels now carry native-histogram queries alongside the classic-bucket ones. The native series renders wherever native histograms are retained and gives full resolution; the classic queries stay for stacks that keep only buckets.
user: zalegrala
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ This endpoint is useful for diagnosing stalled jobs, verifying that workers are
| `tempo_backend_scheduler_jobs_completed_total` | Jobs completed successfully |
| `tempo_backend_scheduler_jobs_failed_total` | Jobs that failed |
| `tempo_backend_scheduler_jobs_active` | Jobs currently assigned to a worker |
| `tempo_backend_scheduler_jobs_pending` | Jobs enqueued and not yet assigned to a worker. Unlike `jobs_active`, which is bounded by the number of workers, this is queue depth and indicates whether more worker capacity is needed |
| `tempo_backend_scheduler_job_duration_seconds` | Job execution duration histogram |
| `tempodb_blocklist_length` | Number of live blocks per tenant; high values indicate compaction is falling behind |
| `tempodb_compaction_outstanding_blocks` | Outstanding blocks awaiting compaction per tenant; the primary autoscaling signal |
Expand Down
10 changes: 10 additions & 0 deletions modules/backendscheduler/backendscheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
}

mergedJobs chan *work.Job

// publishedPendingLabels records the jobs_pending label sets published last tick, so a drained
// queue can be deleted without resetting the whole vector. Touched only from the maintenance loop.
publishedPendingLabels map[[2]string]struct{}
}

// ListJobs returns all jobs in the work cache
Expand Down Expand Up @@ -220,6 +224,11 @@
backendFlushTicker := time.NewTicker(s.cfg.BackendFlushInterval)
defer backendFlushTicker.Stop()

// Publish once up front: on the tick alone the metric would be absent for a full
// MaintenanceInterval after start, so a dashboard or autoscaler reading it right after a restart
// would see nothing rather than the queue that survived the restart.
s.recordPendingJobs()

var err error

for {
Expand All @@ -230,6 +239,7 @@
s.work.Prune(ctx)
s.checkPendingRescans(ctx)
s.cleanupOrphanedBatches(ctx)
s.recordPendingJobs()

Check notice on line 242 in modules/backendscheduler/backendscheduler.go

View workflow job for this annotation

GitHub Actions / Coverage Annotations

Uncovered line

Line 242 is not covered by tests
case <-backendFlushTicker.C:
err = s.flushWorkCacheToBackend(ctx)
metricWorkFlushes.Inc()
Expand Down
53 changes: 49 additions & 4 deletions modules/backendscheduler/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ var (
Name: "backend_scheduler_jobs_active",
Help: "Number of currently active jobs",
}, []string{"tenant", "job_type"})
metricJobsPending = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "tempo",
Name: "backend_scheduler_jobs_pending",
Help: "Number of jobs enqueued and not yet dispatched to a worker, by tenant and type. This is queue depth: jobs_active counts work already handed out, so it is bounded by the worker count and cannot indicate that more capacity is needed.",
}, []string{"tenant", "job_type"})
metricJobsRetry = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "tempo",
Name: "backend_scheduler_jobs_retry_total",
Expand Down Expand Up @@ -70,10 +75,18 @@ var (
NativeHistogramMinResetDuration: 1 * time.Hour,
})
metricJobDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "tempo",
Name: "backend_scheduler_job_duration_seconds",
Help: "Duration of of a job in seconds",
Buckets: prometheus.DefBuckets,
Namespace: "tempo",
Name: "backend_scheduler_job_duration_seconds",
Help: "Duration of a job in seconds",
// DefBuckets stops at 10s and puts nothing between 2.5s and 5s, where roughly 60% of
// redaction jobs land — every percentile drawn from it interpolates inside one bucket, so
// p50, p90 and p99 move together and say nothing. It also cannot represent a job slower
// than 10s at all, and a redaction over a large block runs for minutes.
//
// Powers of two from 10ms to ~11m: fast retention jobs stay resolved, the redaction mass
// splits across the 2.56s and 5.12s boundaries, and long jobs land in a real bucket
// instead of +Inf.
Buckets: prometheus.ExponentialBuckets(0.01, 2, 17),
NativeHistogramBucketFactor: 1.1,
NativeHistogramMaxBucketNumber: 100,
NativeHistogramMinResetDuration: 1 * time.Hour,
Expand Down Expand Up @@ -101,3 +114,35 @@ func redactionModeLabel(mode tempopb.RedactionMode) string {
}
return "apply"
}

// recordPendingJobs publishes the queue depth per tenant and job type.
//
// Current values are set BEFORE drained series are removed, rather than resetting the vector first.
// A scrape landing inside a Reset would see series missing and read the total lower than it is —
// which is precisely the spurious scale-down this metric exists to prevent. Setting first means a
// scrape can only ever catch a slightly stale value, so the error biases toward holding scale.
//
// Drained queues must still be removed: a tenant whose queue empties stops appearing in the
// snapshot rather than reporting zero, so its last non-zero value would otherwise persist for the
// process lifetime and an autoscaler would hold scale forever.
//
// Called only from the maintenance loop, so the label bookkeeping needs no lock.
func (s *BackendScheduler) recordPendingJobs() {
current := make(map[[2]string]struct{}, len(s.publishedPendingLabels))

for tenant, byType := range s.work.PendingJobCounts() {
for jobType, n := range byType {
labels := [2]string{tenant, jobType.String()}
current[labels] = struct{}{}
metricJobsPending.WithLabelValues(labels[0], labels[1]).Set(float64(n))
}
}

for labels := range s.publishedPendingLabels {
if _, still := current[labels]; !still {
metricJobsPending.DeleteLabelValues(labels[0], labels[1])
}
}

s.publishedPendingLabels = current
}
74 changes: 74 additions & 0 deletions modules/backendscheduler/metrics_pending_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package backendscheduler

import (
"testing"

"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"

"github.com/grafana/tempo/modules/backendscheduler/work"
"github.com/grafana/tempo/pkg/tempopb"
)

// TestRecordPendingJobs covers publishing queue depth, and in particular that a drained queue stops
// being reported while a surviving one keeps its value.
//
// The gauge is keyed by tenant, and a tenant whose queue empties stops appearing in the snapshot — it
// never reports zero. Without removing those series their last non-zero value would persist for the
// process lifetime: a finished redaction would look permanently backlogged, and an autoscaler
// triggering on this would hold the scale-up forever.
//
// Removal is deliberately done by deleting drained label sets rather than resetting the vector. A
// scrape landing inside a Reset would observe series missing and read the total lower than it is,
// which is the spurious scale-down this metric exists to prevent.
func TestRecordPendingJobs(t *testing.T) {
metricJobsPending.Reset() // isolate from other tests in this package
s := &BackendScheduler{work: work.New(work.Config{})}

redaction := func(id, tenant, block string) *work.Job {
return &work.Job{
ID: id,
Type: tempopb.JobType_JOB_TYPE_REDACTION,
JobDetail: tempopb.JobDetail{
Tenant: tenant,
Redaction: &tempopb.RedactionDetail{BlockId: block},
},
}
}

require.NoError(t, s.work.AddPendingJobs([]*work.Job{
redaction("r1", "tenant-a", "block-1"),
redaction("r2", "tenant-a", "block-2"),
redaction("r3", "tenant-b", "block-1"),
{
ID: "c1",
Type: tempopb.JobType_JOB_TYPE_COMPACTION,
JobDetail: tempopb.JobDetail{
Tenant: "tenant-a",
Compaction: &tempopb.CompactionDetail{Input: []string{"block-9"}},
},
},
}))

s.recordPendingJobs()
require.Equal(t, 2.0, testutil.ToFloat64(metricJobsPending.WithLabelValues("tenant-a", "JOB_TYPE_REDACTION")))
require.Equal(t, 1.0, testutil.ToFloat64(metricJobsPending.WithLabelValues("tenant-b", "JOB_TYPE_REDACTION")))
require.Equal(t, 1.0, testutil.ToFloat64(metricJobsPending.WithLabelValues("tenant-a", "JOB_TYPE_COMPACTION")))
require.Equal(t, 3, testutil.CollectAndCount(metricJobsPending), "one series per tenant and type with queued work")

// Drain every redaction job. NextPendingJob is type-scoped, so the compaction job stays queued
// and tenant-a keeps exactly one series while tenant-b loses its only one.
for s.work.NextPendingJob(tempopb.JobType_JOB_TYPE_REDACTION) != nil { //nolint:revive // drain
}

s.recordPendingJobs()
require.Equal(t, 1, testutil.CollectAndCount(metricJobsPending),
"drained queues must lose their series rather than keep their last value")
require.Equal(t, 1.0, testutil.ToFloat64(metricJobsPending.WithLabelValues("tenant-a", "JOB_TYPE_COMPACTION")),
"the queue that was never drained keeps its value; only drained series are removed")

// Publishing again with nothing changed must be stable, not oscillate as labels are re-recorded.
s.recordPendingJobs()
require.Equal(t, 1, testutil.CollectAndCount(metricJobsPending), "a repeat publish must be idempotent")
require.Equal(t, 1.0, testutil.ToFloat64(metricJobsPending.WithLabelValues("tenant-a", "JOB_TYPE_COMPACTION")))
}
3 changes: 3 additions & 0 deletions modules/backendscheduler/work/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ type Interface interface {
// Acquires pendingMtx exactly once and returns a snapshot.
BusyBlocksForTenant(tenantID string) map[string]string

// PendingJobCounts returns enqueued, not-yet-dispatched job counts per tenant and type.
PendingJobCounts() map[string]map[tempopb.JobType]int

// TenantPending returns true when an exclusive tenant operation exists whose
// full scope is not yet reflected in the job queue — i.e. an apply-mode redaction batch
// (just created or in its rescan-wait window). Gates compaction and retention. A dry-run
Expand Down
63 changes: 63 additions & 0 deletions modules/backendscheduler/work/pending_counts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package work

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/grafana/tempo/pkg/tempopb"
)

// TestPendingJobCounts covers the queue-depth snapshot that backs the jobs_pending gauge.
//
// Depth is the only signal that can drive scale-up. jobs_active counts work already handed to a
// worker, so it is bounded by the worker count and rises only as capacity rises — reading it to
// decide whether to add capacity is circular. If this undercounts, an autoscaler stops adding
// workers while work is still queued.
func TestPendingJobCounts(t *testing.T) {
w := New(Config{}).(*Work)

require.Empty(t, w.PendingJobCounts(), "no jobs means no series at all, not zero-valued ones")

require.NoError(t, w.AddPendingJobs([]*Job{
createRedactionJob("r1", "tenant-a", "block-1"),
createRedactionJob("r2", "tenant-a", "block-2"),
createRedactionJob("r3", "tenant-b", "block-1"),
createCompactionJob("c1", "tenant-a", []string{"block-9"}),
}))

counts := w.PendingJobCounts()
require.Equal(t, 2, counts["tenant-a"][tempopb.JobType_JOB_TYPE_REDACTION])
require.Equal(t, 1, counts["tenant-a"][tempopb.JobType_JOB_TYPE_COMPACTION])
require.Equal(t, 1, counts["tenant-b"][tempopb.JobType_JOB_TYPE_REDACTION])

// Dispatching moves a job out of the queue: it is active now, not pending. Counting it in both
// would hold an autoscaler up after the queue had actually drained.
require.NotNil(t, w.NextPendingJob(tempopb.JobType_JOB_TYPE_REDACTION))

counts = w.PendingJobCounts()
remaining := counts["tenant-a"][tempopb.JobType_JOB_TYPE_REDACTION] + counts["tenant-b"][tempopb.JobType_JOB_TYPE_REDACTION]
require.Equal(t, 2, remaining, "a dispatched job must leave the pending depth")

// A drained type disappears rather than reporting zero, which is what lets the gauge's Reset
// clear the series instead of leaving a stale non-zero value an autoscaler would keep reading.
for w.NextPendingJob(tempopb.JobType_JOB_TYPE_REDACTION) != nil { //nolint:revive // drain
}

counts = w.PendingJobCounts()
for tenant, byType := range counts {
_, ok := byType[tempopb.JobType_JOB_TYPE_REDACTION]
require.False(t, ok, "a drained redaction queue must not appear for tenant %s", tenant)
}
require.Equal(t, 1, counts["tenant-a"][tempopb.JobType_JOB_TYPE_COMPACTION], "other job types are unaffected")

// The dequeue path deletes a queue as it empties, so an empty slice only arises from another
// path (a rebuild, or a future removal). Reported as-is it would publish a zero-valued series
// that never clears, which is the same stale-signal problem the gauge's Reset exists to avoid.
w.pendingMtx.Lock()
w.pendingByTenant["tenant-c"] = map[tempopb.JobType][]string{tempopb.JobType_JOB_TYPE_RETENTION: {}}
w.pendingMtx.Unlock()

_, ok := w.PendingJobCounts()["tenant-c"]
require.False(t, ok, "an empty queue must produce no series at all")
}
25 changes: 25 additions & 0 deletions modules/backendscheduler/work/work.go
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,31 @@ func (w *Work) IsBlockBusy(tenantID, blockID string) bool {
return inPending || inRunning
}

// PendingJobCounts returns the number of enqueued, not-yet-dispatched jobs per tenant and type.
//
// This is queue DEPTH, which no other metric carries: jobs_active counts work already handed to a
// worker, so it is bounded by the worker count and can never signal that more capacity is needed.
// Only the pending depth can.
func (w *Work) PendingJobCounts() map[string]map[tempopb.JobType]int {
w.pendingMtx.Lock()
defer w.pendingMtx.Unlock()

counts := make(map[string]map[tempopb.JobType]int, len(w.pendingByTenant))
for tenant, byType := range w.pendingByTenant {
for jobType, queue := range byType {
if len(queue) == 0 {
continue
}
if counts[tenant] == nil {
counts[tenant] = make(map[tempopb.JobType]int, len(byType))
}
counts[tenant][jobType] = len(queue)
}
}

return counts
}

// BusyBlocksForTenant returns a map of blockID -> jobID for every block
// currently referenced by a pending, registered, or active job for the tenant.
// Acquires pendingMtx exactly once and returns a snapshot.
Expand Down
Loading
Loading