Skip to content

Commit aff0c74

Browse files
committed
perf(ingester): invalidate head postings cache per-label instead of per-metric
The head expanded postings cache previously keyed a per-metric "seed" into the cache key, so creating or deleting any series for a metric invalidated every cached entry for that metric name, even entries whose result could not have changed. Replace the seed with a per-(tenant, label) write counter. On series create/delete each of the series' label counts is incremented. On creation, a cache entry snapshots the counts of its matched labels and stays valid as long as at least one tracked count is unchanged. Creating or deleting a series which matches all of the tracked labels expires the cache entries. Matchers that can match an absent label (e.g. foo!="x", foo=~".*") are excluded from the snapshot since a matching series need not carry the label, so they cannot vouch for an entry. Name-only queries fall back to the old whole-metric behaviour via the __name__ count. The counter array is shared across tenants and namespaced by userId. The size is configurable via the newly added expanded_postings_cache.head.label-counter-size flag. Also adds a reason="invalidated" value to the cache miss metric to distinguish count invalidation from TTL expiry. Signed-off-by: Kyle Stang <kylestng@amazon.com>
1 parent d671fa0 commit aff0c74

8 files changed

Lines changed: 663 additions & 175 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
* [ENHANCEMENT] Querier: Detach series label and chunk data from gRPC unmarshal buffers in store-gateway streaming path, allowing the Go GC to reclaim receive buffers. #7519
3838
* [ENHANCEMENT] Distributor: Added `cortex_distributor_received_histogram_buckets` metric to track number of buckets in received native histogram samples before validation, per user. #7569
3939
* [ENHANCEMENT] Ingester: Add lazy regex evaluation on head postings cache miss. Defers expensive regex matchers on high-cardinality labels to per-series filtering when a selective equality matcher already narrows the result set. Configured via `-blocks-storage.expanded_postings_cache.head.lazy-matcher-max-cardinality` (disabled by default). #7553
40+
* [ENHANCEMENT] Ingester: Improve head expanded postings cache hit rate by invalidating only the cache entries whose matched labels changed when a series is created or deleted, instead of invalidating all entries for the metric name. #7678
4041
* [ENHANCEMENT] Store Gateway: Resolve the parquet shard count from the bucket index instead of reading the converter mark for each block, reducing object storage calls when the bucket index is enabled. A `component` label is added to the bucket index loader metrics to distinguish store-queryable and store-gateway. #7648
4142
* [ENHANCEMENT] Query Frontend: Improve the slow query log with `source`, `user_agent`, `engine_type`, `block_store_type`, and query stats fields to aid slow query diagnosis. #7601
4243
* [ENHANCEMENT] Ring: Add ring metric to count number of duplicate tokens. #7626

docs/blocks-storage/querier.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2274,6 +2274,12 @@ blocks_storage:
22742274
# CLI flag: -blocks-storage.expanded_postings_cache.head.lazy-matcher-complex-cost-ratio
22752275
[lazy_matcher_complex_cost_ratio: <int> | default = 2]
22762276

2277+
# The number of counts stored in the label counter used for head cache
2278+
# invalidation. Note one label counter is shared by all tenants. 0 sets to
2279+
# the default of 4_000_000.
2280+
# CLI flag: -blocks-storage.expanded_postings_cache.head.label-counter-size
2281+
[label_counter_size: <int> | default = 4000000]
2282+
22772283
users_scanner:
22782284
# Strategy to use to scan users. Supported values are: list, user_index.
22792285
# CLI flag: -blocks-storage.users-scanner.strategy

docs/blocks-storage/store-gateway.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,6 +2332,12 @@ blocks_storage:
23322332
# CLI flag: -blocks-storage.expanded_postings_cache.head.lazy-matcher-complex-cost-ratio
23332333
[lazy_matcher_complex_cost_ratio: <int> | default = 2]
23342334

2335+
# The number of counts stored in the label counter used for head cache
2336+
# invalidation. Note one label counter is shared by all tenants. 0 sets to
2337+
# the default of 4_000_000.
2338+
# CLI flag: -blocks-storage.expanded_postings_cache.head.label-counter-size
2339+
[label_counter_size: <int> | default = 4000000]
2340+
23352341
users_scanner:
23362342
# Strategy to use to scan users. Supported values are: list, user_index.
23372343
# CLI flag: -blocks-storage.users-scanner.strategy

docs/configuration/config-file-reference.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2923,6 +2923,12 @@ tsdb:
29232923
# CLI flag: -blocks-storage.expanded_postings_cache.head.lazy-matcher-complex-cost-ratio
29242924
[lazy_matcher_complex_cost_ratio: <int> | default = 2]
29252925

2926+
# The number of counts stored in the label counter used for head cache
2927+
# invalidation. Note one label counter is shared by all tenants. 0 sets to
2928+
# the default of 4_000_000.
2929+
# CLI flag: -blocks-storage.expanded_postings_cache.head.label-counter-size
2930+
[label_counter_size: <int> | default = 4000000]
2931+
29262932
users_scanner:
29272933
# Strategy to use to scan users. Supported values are: list, user_index.
29282934
# CLI flag: -blocks-storage.users-scanner.strategy

integration/query_fuzz_test.go

Lines changed: 160 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,8 @@ func TestExpandedPostingsCacheFuzz(t *testing.T) {
433433
// Start dependencies.
434434
consul1 := e2edb.NewConsulWithName("consul1")
435435
consul2 := e2edb.NewConsulWithName("consul2")
436-
require.NoError(t, s.StartAndWaitReady(consul1, consul2))
436+
consul3 := e2edb.NewConsulWithName("consul3")
437+
require.NoError(t, s.StartAndWaitReady(consul1, consul2, consul3))
437438

438439
flags1 := mergeFlags(
439440
AlertmanagerLocalFlags(),
@@ -481,33 +482,56 @@ func TestExpandedPostingsCacheFuzz(t *testing.T) {
481482
// Store-gateway.
482483
"-store-gateway.sharding-enabled": "false",
483484
// alert manager
484-
"-alertmanager.web.external-url": "http://localhost/alertmanager",
485+
"-alertmanager.web.external-url": "http://localhost/alertmanager",
486+
"-alertmanager.cluster.listen-address": "127.0.0.1:9094",
487+
"-alertmanager.cluster.advertise-address": "127.0.0.1:9094",
485488
},
486489
)
487490
// make alert manager config dir
488491
require.NoError(t, writeFileToSharedDir(s, "alertmanager_configs", []byte{}))
489492

490493
path1 := path.Join(s.SharedDir(), "cortex-1")
491494
path2 := path.Join(s.SharedDir(), "cortex-2")
495+
path3 := path.Join(s.SharedDir(), "cortex-3")
492496

493497
flags1 = mergeFlags(flags1, map[string]string{"-blocks-storage.filesystem.dir": path1})
494498
flags2 = mergeFlags(flags2, map[string]string{"-blocks-storage.filesystem.dir": path2})
499+
// cortex-3 matches cortex-2 but squashes the label counter to a single slot, so
500+
// every (tenant, label) collides.
501+
flags3 := mergeFlags(flags2, map[string]string{
502+
"-blocks-storage.filesystem.dir": path3,
503+
"-consul.hostname": consul3.NetworkHTTPEndpoint(),
504+
"-blocks-storage.expanded_postings_cache.head.label-counter-size": "1",
505+
})
495506
// Start Cortex replicas.
496507
cortex1 := e2ecortex.NewSingleBinary("cortex-1", flags1, stableCortexImage)
497508
cortex2 := e2ecortex.NewSingleBinary("cortex-2", flags2, "")
498-
require.NoError(t, s.StartAndWaitReady(cortex1, cortex2))
509+
cortex3 := e2ecortex.NewSingleBinary("cortex-3", flags3, "")
510+
require.NoError(t, s.StartAndWaitReady(cortex1, cortex2, cortex3))
499511

500512
// Wait until Cortex replicas have updated the ring state.
501513
require.NoError(t, cortex1.WaitSumMetrics(e2e.Equals(float64(512)), "cortex_ring_tokens_total"))
502514
require.NoError(t, cortex2.WaitSumMetrics(e2e.Equals(float64(512)), "cortex_ring_tokens_total"))
515+
require.NoError(t, cortex3.WaitSumMetrics(e2e.Equals(float64(512)), "cortex_ring_tokens_total"))
503516

504517
var clients []*e2ecortex.Client
505518
c1, err := e2ecortex.NewClient(cortex1.HTTPEndpoint(), cortex1.HTTPEndpoint(), "", "", "user-1")
506519
require.NoError(t, err)
507520
c2, err := e2ecortex.NewClient(cortex2.HTTPEndpoint(), cortex2.HTTPEndpoint(), "", "", "user-1")
508521
require.NoError(t, err)
522+
c3, err := e2ecortex.NewClient(cortex3.HTTPEndpoint(), cortex3.HTTPEndpoint(), "", "", "user-1")
523+
require.NoError(t, err)
524+
525+
clients = append(clients, c1, c2, c3)
509526

510-
clients = append(clients, c1, c2)
527+
// c1 (no postings cache) is the oracle; every candidate must agree with it.
528+
candidates := []struct {
529+
name string
530+
client *e2ecortex.Client
531+
}{
532+
{"postings-cache", c2},
533+
{"postings-cache-colliding-counter", c3},
534+
}
511535

512536
now := time.Now()
513537
// Push some series to Cortex.
@@ -517,28 +541,47 @@ func TestExpandedPostingsCacheFuzz(t *testing.T) {
517541
numSeries := 10
518542
numberOfLabelsPerSeries := 5
519543
numSamples := 10
520-
ss := make([]prompb.TimeSeries, numSeries*numberOfLabelsPerSeries)
521-
lbls := make([]labels.Labels, numSeries*numberOfLabelsPerSeries)
522-
523-
for i := 0; i < numSeries; i++ {
524-
for j := 0; j < numberOfLabelsPerSeries; j++ {
525-
series := e2e.GenerateSeriesWithSamples(
526-
fmt.Sprintf("test_series_%d", i),
527-
start,
528-
scrapeInterval,
529-
i*numSamples,
530-
numSamples,
531-
prompb.Label{Name: "test_label", Value: fmt.Sprintf("test_label_value_%d", j)},
532-
)
533-
ss[i*numberOfLabelsPerSeries+j] = series
534-
535-
builder := labels.NewBuilder(labels.EmptyLabels())
536-
for _, lbl := range series.Labels {
537-
builder.Set(lbl.Name, lbl.Value)
544+
numIterations := 5
545+
546+
// Names and values that only exist from iteration k onwards, so entries cached
547+
// earlier (including empty ones) invalidate only if a value-keyed slot moves.
548+
lateSeriesName := func(k int) string { return fmt.Sprintf("test_series_late_%d", k) }
549+
lateLabelValue := func(k int) string { return fmt.Sprintf("test_label_value_late_%d", k) }
550+
551+
// "k" is the only label that churns, so it has to be in promqlsmith's vocabulary
552+
// or no generated matcher ever constrains it.
553+
lbls := make([]labels.Labels, 0, numSeries*numberOfLabelsPerSeries*numIterations+3*numIterations)
554+
for i := range numSeries {
555+
for j := range numberOfLabelsPerSeries {
556+
for k := range numIterations {
557+
lbls = append(lbls, labels.FromStrings(
558+
model.MetricNameLabel, fmt.Sprintf("test_series_%d", i),
559+
"test_label", fmt.Sprintf("test_label_value_%d", j),
560+
"k", fmt.Sprintf("%d", k),
561+
))
538562
}
539-
lbls[i*numberOfLabelsPerSeries+j] = builder.Labels()
540563
}
541564
}
565+
// Late names/values need to be in the vocabulary too. The third shape omits
566+
// test_label, so only matchers that can match an absent label select it.
567+
for k := range numIterations {
568+
lbls = append(lbls,
569+
labels.FromStrings(
570+
model.MetricNameLabel, lateSeriesName(k),
571+
"test_label", fmt.Sprintf("test_label_value_%d", k%numberOfLabelsPerSeries),
572+
"k", fmt.Sprintf("%d", k),
573+
),
574+
labels.FromStrings(
575+
model.MetricNameLabel, fmt.Sprintf("test_series_%d", k%numSeries),
576+
"test_label", lateLabelValue(k),
577+
"k", fmt.Sprintf("%d", k),
578+
),
579+
labels.FromStrings(
580+
model.MetricNameLabel, fmt.Sprintf("test_series_%d", k%numSeries),
581+
"k", fmt.Sprintf("%d", k),
582+
),
583+
)
584+
}
542585

543586
rnd := newFuzzRand(t)
544587
opts := []promqlsmith.Option{
@@ -551,7 +594,7 @@ func TestExpandedPostingsCacheFuzz(t *testing.T) {
551594
testRun := 300
552595
queries := make([]string, 0, testRun)
553596
matchers := make([]string, 0, testRun)
554-
for i := 0; i < testRun; i++ {
597+
for i := range testRun {
555598
var expr parser.Expr
556599
for {
557600
expr = ps.WalkRangeQuery()
@@ -568,96 +611,148 @@ func TestExpandedPostingsCacheFuzz(t *testing.T) {
568611
}
569612

570613
// Lets run multiples iterations and create new series every iteration
571-
for k := 0; k < 5; k++ {
572-
573-
nss := make([]prompb.TimeSeries, numSeries*numberOfLabelsPerSeries)
574-
for i := 0; i < numSeries; i++ {
575-
for j := 0; j < numberOfLabelsPerSeries; j++ {
576-
nss[i*numberOfLabelsPerSeries+j] = e2e.GenerateSeriesWithSamples(
614+
for k := range numIterations {
615+
616+
// Churn a subset only. Rewriting every combination moves every snapshotted
617+
// slot, so entries would always invalidate and never stay valid.
618+
timeSeries := make([]prompb.TimeSeries, 0, numSeries*numberOfLabelsPerSeries+numSeries+2)
619+
for i := range numSeries {
620+
for j := range numberOfLabelsPerSeries {
621+
if (i+j+k)%3 == 0 {
622+
continue
623+
}
624+
timeSeries = append(timeSeries, e2e.GenerateSeriesWithSamples(
577625
fmt.Sprintf("test_series_%d", i),
578626
start.Add(scrapeInterval*time.Duration(numSamples*j)),
579627
scrapeInterval,
580628
i*numSamples,
581629
numSamples,
582630
prompb.Label{Name: "test_label", Value: fmt.Sprintf("test_label_value_%d", j)},
583631
prompb.Label{Name: "k", Value: fmt.Sprintf("%d", k)},
584-
)
632+
))
633+
}
634+
}
635+
636+
// First appearance of this iteration's late metric name and test_label value.
637+
timeSeries = append(timeSeries,
638+
e2e.GenerateSeriesWithSamples(
639+
lateSeriesName(k),
640+
start,
641+
scrapeInterval,
642+
k*numSamples,
643+
numSamples,
644+
prompb.Label{Name: "test_label", Value: fmt.Sprintf("test_label_value_%d", k%numberOfLabelsPerSeries)},
645+
prompb.Label{Name: "k", Value: fmt.Sprintf("%d", k)},
646+
),
647+
e2e.GenerateSeriesWithSamples(
648+
fmt.Sprintf("test_series_%d", k%numSeries),
649+
start,
650+
scrapeInterval,
651+
k*numSamples,
652+
numSamples,
653+
prompb.Label{Name: "test_label", Value: lateLabelValue(k)},
654+
prompb.Label{Name: "k", Value: fmt.Sprintf("%d", k)},
655+
),
656+
)
657+
658+
// Series that omit test_label. They never bump the test_label slots, which is
659+
// why absent-label matchers cannot vouch for an entry.
660+
for i := range numSeries {
661+
if (i+k)%2 != 0 {
662+
continue
585663
}
664+
timeSeries = append(timeSeries, e2e.GenerateSeriesWithSamples(
665+
fmt.Sprintf("test_series_%d", i),
666+
start,
667+
scrapeInterval,
668+
i*numSamples,
669+
numSamples,
670+
prompb.Label{Name: "k", Value: fmt.Sprintf("%d", k)},
671+
))
586672
}
587673

588674
for _, client := range clients {
589-
res, err := client.Push(nss)
675+
res, err := client.Push(timeSeries)
590676
require.NoError(t, err)
591677
require.Equal(t, 200, res.StatusCode)
592678
}
593679

594680
type testCase struct {
595681
query string
596682
qt string
683+
instance string
597684
res1, res2 model.Value
598685
sres1, sres2 []model.LabelSet
599686
err1, err2 error
600687
}
601688

602-
cases := make([]*testCase, 0, len(queries)*3)
689+
cases := make([]*testCase, 0, len(queries)*3*len(candidates))
603690

604691
for _, query := range queries {
605692
fuzzyTime := time.Duration(rand.Int63n(time.Now().UnixMilli() - start.UnixMilli()))
606693
queryEnd := start.Add(fuzzyTime * time.Millisecond)
607-
res1, err1 := c1.Query(query, queryEnd)
608-
res2, err2 := c2.Query(query, queryEnd)
609-
cases = append(cases, &testCase{
610-
query: query,
611-
qt: "instant",
612-
res1: res1,
613-
res2: res2,
614-
err1: err1,
615-
err2: err2,
616-
})
617-
res1, err1 = c1.QueryRange(query, start, queryEnd, scrapeInterval)
618-
res2, err2 = c2.QueryRange(query, start, queryEnd, scrapeInterval)
619-
cases = append(cases, &testCase{
620-
query: query,
621-
qt: "range query",
622-
res1: res1,
623-
res2: res2,
624-
err1: err1,
625-
err2: err2,
626-
})
694+
// Resolve the oracle once per query and compare every candidate to it.
695+
oracleInstant, oracleInstantErr := c1.Query(query, queryEnd)
696+
oracleRange, oracleRangeErr := c1.QueryRange(query, start, queryEnd, scrapeInterval)
697+
for _, cand := range candidates {
698+
res2, err2 := cand.client.Query(query, queryEnd)
699+
cases = append(cases, &testCase{
700+
query: query,
701+
qt: "instant",
702+
instance: cand.name,
703+
res1: oracleInstant,
704+
res2: res2,
705+
err1: oracleInstantErr,
706+
err2: err2,
707+
})
708+
res2, err2 = cand.client.QueryRange(query, start, queryEnd, scrapeInterval)
709+
cases = append(cases, &testCase{
710+
query: query,
711+
qt: "range query",
712+
instance: cand.name,
713+
res1: oracleRange,
714+
res2: res2,
715+
err1: oracleRangeErr,
716+
err2: err2,
717+
})
718+
}
627719
}
628720

629721
for _, m := range matchers {
630722
fuzzyTime := time.Duration(rand.Int63n(time.Now().UnixMilli() - start.UnixMilli()))
631723
queryEnd := start.Add(fuzzyTime * time.Millisecond)
632724
res1, err := c1.Series([]string{m}, start, queryEnd)
633725
require.NoError(t, err)
634-
res2, err := c2.Series([]string{m}, start, queryEnd)
635-
require.NoError(t, err)
636-
cases = append(cases, &testCase{
637-
query: m,
638-
qt: "get series",
639-
sres1: res1,
640-
sres2: res2,
641-
})
726+
for _, cand := range candidates {
727+
res2, err := cand.client.Series([]string{m}, start, queryEnd)
728+
require.NoError(t, err)
729+
cases = append(cases, &testCase{
730+
query: m,
731+
qt: "get series",
732+
instance: cand.name,
733+
sres1: res1,
734+
sres2: res2,
735+
})
736+
}
642737
}
643738

644739
failures := 0
645740
for i, tc := range cases {
646741
if tc.err1 != nil || tc.err2 != nil {
647742
if !sameErrorClass(tc.err1, tc.err2) {
648-
t.Logf("case %d error mismatch.\n%s: %s\nerr1: %v\nerr2: %v\n", i, tc.qt, tc.query, tc.err1, tc.err2)
743+
t.Logf("case %d [%s] error mismatch.\n%s: %s\nerr1: %v\nerr2: %v\n", i, tc.instance, tc.qt, tc.query, tc.err1, tc.err2)
649744
failures++
650745
}
651746
} else if shouldUseSampleNumComparer(tc.query) {
652747
if !cmp.Equal(tc.res1, tc.res2, sampleNumComparer) {
653-
t.Logf("case %d # of samples mismatch.\n%s: %s\nres1: %s\nres2: %s\n", i, tc.qt, tc.query, tc.res1.String(), tc.res2.String())
748+
t.Logf("case %d [%s] # of samples mismatch.\n%s: %s\nres1: %s\nres2: %s\n", i, tc.instance, tc.qt, tc.query, tc.res1.String(), tc.res2.String())
654749
failures++
655750
}
656751
} else if !cmp.Equal(tc.res1, tc.res2, comparer) {
657-
t.Logf("case %d results mismatch.\n%s: %s\nres1: %s\nres2: %s\n", i, tc.qt, tc.query, tc.res1.String(), tc.res2.String())
752+
t.Logf("case %d [%s] results mismatch.\n%s: %s\nres1: %s\nres2: %s\n", i, tc.instance, tc.qt, tc.query, tc.res1.String(), tc.res2.String())
658753
failures++
659754
} else if !cmp.Equal(tc.sres1, tc.sres2, labelSetsComparer) {
660-
t.Logf("case %d results mismatch.\n%s: %s\nsres1: %s\nsres2: %s\n", i, tc.qt, tc.query, tc.sres1, tc.sres2)
755+
t.Logf("case %d [%s] results mismatch.\n%s: %s\nsres1: %s\nsres2: %s\n", i, tc.instance, tc.qt, tc.query, tc.sres1, tc.sres2)
661756
failures++
662757
}
663758
}

0 commit comments

Comments
 (0)