diff --git a/CHANGELOG.md b/CHANGELOG.md index 627703e0889..b4229a6900b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ * [ENHANCEMENT] Block-builder-scheduler: Add `cortex_blockbuilder_scheduler_end_offset_probe_failed_total`, counting failures to list a cluster's end offsets, and `cortex_blockbuilder_scheduler_startup_jobs_skipped_total`, counting observed jobs that startup recovery could not import. #16134 * [FEATURE] Querier: Add experimental per-tenant limit `-querier.max-blocks-per-store-request` to cap the number of blocks a single store-gateway request may reference. Disabled by default. #16292 * [FEATURE] Validation: Add optional `id`, `note`, `created_by`, `created_at`, and `expires_at` fields to `blocked_queries` and `limited_queries` rules, for tooling to attach ownership/context metadata to a rule. For rules with `expires_at` set, the earliest `expires_at` per tenant and `id` (rules without an `id` are grouped together) is exported as the `cortex_blocked_query_rule_expires_at`/`cortex_limited_query_rule_expires_at` metrics, so an alert can fire on stale rules; this is informational only and never affects enforcement. The query-frontend's `"query blocked"` log line now also includes the matched rule's `id` and whether it is expired, and rate-limited queries are now logged with a new `"query limited"` line carrying the same fields. #16395 +* [BUGFIX] Compactor: Honor the per-tenant `float_chunk_encoding` limit (`-ingester.float-chunk-encoding`) when re-encoding float chunks during compaction. Previously the compactor was built without a float chunk encoding, so every float chunk it re-encoded was written back as `xor`, undoing `xor2` for tenants that had it enabled. Only chunks that overlap in time are re-encoded, so compacted blocks can stay mixed-encoding, and blocks already compacted are not repaired. #16488 * [BUGFIX] Query-frontend: Wait for the querier ring to be populated during startup, up to 30 seconds, before reporting the query-frontend as ready. Previously a query-frontend could become ready before it had seen any querier in the ring and fail every query it received until the ring was populated. Only applies when remote execution is enabled, and can be disabled with the experimental `-query-frontend.wait-for-querier-ring-on-startup=false`. #16333 * [BUGFIX] Query-frontend: Fail queries with a clear error, rather than planning them against an invalid maximum supported query plan version, when the querier ring contains only unhealthy queriers. #16333 * [BUGFIX] Query-frontend: Fix `cortex_query_frontend_queries_in_progress` drifting permanently below zero. The response body returned to the middleware chain is closed more than once, and every close decremented the gauge against a single increment. #16429 diff --git a/cmd/mimir/config-descriptor.json b/cmd/mimir/config-descriptor.json index 80a64bf3540..ed7a6e11b4c 100644 --- a/cmd/mimir/config-descriptor.json +++ b/cmd/mimir/config-descriptor.json @@ -5962,7 +5962,7 @@ "kind": "field", "name": "float_chunk_encoding", "required": false, - "desc": "Encoding used for float chunks in the ingester and block builder for this tenant. Valid values are 'xor' and 'xor2'.", + "desc": "Encoding used for float chunks written for this tenant by the ingester and block-builder, and by the compactor when it re-encodes overlapping chunks. Supported values are: xor, xor2.", "fieldValue": null, "fieldDefaultValue": "xor", "fieldFlag": "ingester.float-chunk-encoding", diff --git a/cmd/mimir/help-all.txt.tmpl b/cmd/mimir/help-all.txt.tmpl index 2596ebd5263..b7427ba2e0a 100644 --- a/cmd/mimir/help-all.txt.tmpl +++ b/cmd/mimir/help-all.txt.tmpl @@ -1908,7 +1908,7 @@ Usage of ./cmd/mimir/mimir: -ingester.error-sample-rate int Each error will be logged once in this many times. Use 0 to log all of them. (default 10) -ingester.float-chunk-encoding string - [experimental] Encoding used for float chunks in the ingester and block builder for this tenant. Valid values are 'xor' and 'xor2'. (default "xor") + [experimental] Encoding used for float chunks written for this tenant by the ingester and block-builder, and by the compactor when it re-encodes overlapping chunks. Supported values are: xor, xor2. (default "xor") -ingester.ignore-ooo-exemplars [experimental] Whether to ignore exemplars with out-of-order timestamps. If enabled, exemplars with out-of-order timestamps are silently dropped, otherwise they cause partial errors. -ingester.ignore-series-limit-for-metric-names string diff --git a/docs/sources/mimir/configure/configuration-parameters/index.md b/docs/sources/mimir/configure/configuration-parameters/index.md index e13c1f685e3..47e194fe0bb 100644 --- a/docs/sources/mimir/configure/configuration-parameters/index.md +++ b/docs/sources/mimir/configure/configuration-parameters/index.md @@ -4636,8 +4636,9 @@ The `limits` block configures default and per-tenant limits imposed by component # CLI flag: -ingester.native-histograms-ingestion-enabled [native_histograms_ingestion_enabled: | default = true] -# (experimental) Encoding used for float chunks in the ingester and block -# builder for this tenant. Valid values are 'xor' and 'xor2'. +# (experimental) Encoding used for float chunks written for this tenant by the +# ingester and block-builder, and by the compactor when it re-encodes +# overlapping chunks. Supported values are: xor, xor2. # CLI flag: -ingester.float-chunk-encoding [float_chunk_encoding: | default = "xor"] diff --git a/pkg/compactor/blocks_cleaner_test.go b/pkg/compactor/blocks_cleaner_test.go index b34e5ce2a18..5b367ea73d6 100644 --- a/pkg/compactor/blocks_cleaner_test.go +++ b/pkg/compactor/blocks_cleaner_test.go @@ -28,6 +28,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/testutil" prom_tsdb "github.com/prometheus/prometheus/tsdb" + "github.com/prometheus/prometheus/tsdb/chunkenc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/thanos-io/objstore" @@ -39,6 +40,7 @@ import ( mimir_testutil "github.com/grafana/mimir/pkg/storage/tsdb/testutil" "github.com/grafana/mimir/pkg/util" "github.com/grafana/mimir/pkg/util/test" + "github.com/grafana/mimir/pkg/util/validation" ) type testBlocksCleanerOptions struct { @@ -1857,6 +1859,7 @@ func (m *mockBucketFailure) Delete(ctx context.Context, name string) error { } type mockConfigProvider struct { + floatChunkEncodings map[string]chunkenc.Encoding userRetentionPeriods map[string]time.Duration splitAndMergeShards map[string]int oooSplitAndMergeShards map[string]int @@ -1874,6 +1877,7 @@ type mockConfigProvider struct { func newMockConfigProvider() *mockConfigProvider { return &mockConfigProvider{ + floatChunkEncodings: make(map[string]chunkenc.Encoding), userRetentionPeriods: make(map[string]time.Duration), splitAndMergeShards: make(map[string]int), oooSplitAndMergeShards: make(map[string]int), @@ -1944,6 +1948,13 @@ func (m *mockConfigProvider) CompactorBlockUploadMaxBlockSizeBytes(user string) return m.blockUploadMaxBlockSizeBytes[user] } +func (m *mockConfigProvider) FloatChunkEncoding(userID string) chunkenc.Encoding { + if result, ok := m.floatChunkEncodings[userID]; ok { + return result + } + return validation.ParseFloatChunkEncoding(validation.DefaultFloatChunkEncodingValue) +} + func (m *mockConfigProvider) S3SSEType(string) string { return "" } diff --git a/pkg/compactor/compactor.go b/pkg/compactor/compactor.go index 95e02a051a3..21565ebc7c2 100644 --- a/pkg/compactor/compactor.go +++ b/pkg/compactor/compactor.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/dskit/services" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/prometheus/tsdb/chunkenc" "github.com/thanos-io/objstore" "go.uber.org/atomic" @@ -86,13 +87,18 @@ type BlocksGrouperFactory func( reg prometheus.Registerer, ) Grouper -// BlocksCompactorFactory builds and returns the compactor and planner for compacting a tenant's blocks. +// BlocksCompactorFactory builds and returns the compactor provider and planner for compacting tenants' blocks. type BlocksCompactorFactory func( ctx context.Context, cfg Config, + cfgProvider ConfigProvider, logger log.Logger, reg prometheus.Registerer, -) (Compactor, Planner, error) +) (BlocksCompactorProvider, Planner, error) + +// BlocksCompactorProvider returns the Compactor to use for a given tenant's blocks. It must be safe +// for concurrent use, because jobs belonging to different tenants can be compacted at the same time. +type BlocksCompactorProvider func(userID string) Compactor // Config holds the MultitenantCompactor config. type Config struct { @@ -286,6 +292,10 @@ type ConfigProvider interface { // CompactorMaxPerBlockUploadConcurrency returns the maximum number of TSDB files that can be uploaded concurrently for each block. CompactorMaxPerBlockUploadConcurrency(userID string) int + + // FloatChunkEncoding returns the encoding to use for float chunks written for a given user. + // An encoding no -ingester.float-chunk-encoding value selects is treated as the default. + FloatChunkEncoding(userID string) chunkenc.Encoding } // MultitenantCompactor is a multi-tenant TSDB block compactor based on Thanos. @@ -308,9 +318,9 @@ type MultitenantCompactor struct { // Blocks cleaner is responsible for hard deletion of blocks marked for deletion. blocksCleaner *BlocksCleaner - // Underlying compactor and planner for compacting TSDB blocks. - blocksCompactor Compactor - blocksPlanner Planner + // Underlying compactor provider and planner for compacting TSDB blocks. + blocksCompactorProvider BlocksCompactorProvider + blocksPlanner Planner // Client used to run operations on the bucket storing blocks. bucketClient objstore.Bucket @@ -571,7 +581,7 @@ func (c *MultitenantCompactor) starting(ctx context.Context) error { } // Create blocks compactor dependencies. - c.blocksCompactor, c.blocksPlanner, err = c.blocksCompactorFactory(ctx, c.compactorCfg, c.logger, c.registerer) + c.blocksCompactorProvider, c.blocksPlanner, err = c.blocksCompactorFactory(ctx, c.compactorCfg, c.cfgProvider, c.logger, c.registerer) if err != nil { return fmt.Errorf("failed to initialize compactor dependencies: %w", err) } @@ -944,7 +954,7 @@ func (c *MultitenantCompactor) newBucketCompactor(ctx context.Context, userID st userLogger, c.blocksGrouperFactory(ctx, c.compactorCfg, c.cfgProvider, userID, userLogger, reg), c.blocksPlanner, - c.blocksCompactor, + c.blocksCompactorProvider(userID), compactDir, userBucket, c.compactorCfg.CompactionConcurrency, diff --git a/pkg/compactor/compactor_test.go b/pkg/compactor/compactor_test.go index 7fdfd5d92fa..75d295632f5 100644 --- a/pkg/compactor/compactor_test.go +++ b/pkg/compactor/compactor_test.go @@ -1884,8 +1884,8 @@ func prepareWithConfigProvider(t *testing.T, compactorCfg Config, bucketClient o return bucketClient, nil } - blocksCompactorFactory := func(context.Context, Config, log.Logger, prometheus.Registerer) (Compactor, Planner, error) { - return tsdbCompactor, tsdbPlanner, nil + blocksCompactorFactory := func(context.Context, Config, ConfigProvider, log.Logger, prometheus.Registerer) (BlocksCompactorProvider, Planner, error) { + return func(string) Compactor { return tsdbCompactor }, tsdbPlanner, nil } c, err := newMultitenantCompactor(compactorCfg, storageCfg, limits, logger, registry, bucketClientFactory, splitAndMergeGrouperFactory, blocksCompactorFactory) diff --git a/pkg/compactor/executor_test.go b/pkg/compactor/executor_test.go index e769c50be4d..655fec1d6fb 100644 --- a/pkg/compactor/executor_test.go +++ b/pkg/compactor/executor_test.go @@ -162,8 +162,11 @@ func newTestSchedulerExecutor(t *testing.T, cfg Config, client compactorschedule func prepareCompactorForExecutorTest(t *testing.T, cfg Config, bkt objstore.Bucket, cfgProvider ConfigProvider) *MultitenantCompactor { t.Helper() - c, _, _, _, _ := prepareWithConfigProvider(t, cfg, bkt, cfgProvider) + c, tsdbCompactor, _, _, _ := prepareWithConfigProvider(t, cfg, bkt, cfgProvider) c.bucketClient = bkt + // These tests don't start the service, so the dependencies normally built by starting() have to + // be installed by hand. + c.blocksCompactorProvider = func(string) Compactor { return tsdbCompactor } c.shardingStrategy = newSplitAndMergeShardingStrategy(nil, nil, nil, c.cfgProvider) return c } @@ -911,9 +914,9 @@ func TestSchedulerExecutor_ExecuteCompactionJob_Compaction(t *testing.T) { schedulerExec := newTestSchedulerExecutor(t, cfg, nil) c := prepareCompactorForExecutorTest(t, cfg, bkt, mockCfg) - compactor, planner, err := splitAndMergeCompactorFactory(context.Background(), cfg, log.NewNopLogger(), prometheus.NewRegistry()) + compactor, planner, err := splitAndMergeCompactorFactory(t.Context(), cfg, mockCfg, log.NewNopLogger(), prometheus.NewRegistry()) require.NoError(t, err) - c.blocksCompactor = compactor + c.blocksCompactorProvider = compactor c.blocksPlanner = planner blockIDBytes := make([][]byte, len(setup.blockIDsToCompact)) diff --git a/pkg/compactor/split_merge_compactor.go b/pkg/compactor/split_merge_compactor.go index 351284497c3..4b86f890be1 100644 --- a/pkg/compactor/split_merge_compactor.go +++ b/pkg/compactor/split_merge_compactor.go @@ -4,12 +4,15 @@ package compactor import ( "context" + "fmt" "github.com/go-kit/log" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/prometheus/tsdb" + "github.com/prometheus/prometheus/tsdb/chunkenc" util_log "github.com/grafana/mimir/pkg/util/log" + "github.com/grafana/mimir/pkg/util/validation" ) func splitAndMergeGrouperFactory(_ context.Context, cfg Config, cfgProvider ConfigProvider, userID string, logger log.Logger, _ prometheus.Registerer) Grouper { @@ -20,22 +23,48 @@ func splitAndMergeGrouperFactory(_ context.Context, cfg Config, cfgProvider Conf logger) } -func splitAndMergeCompactorFactory(ctx context.Context, cfg Config, logger log.Logger, reg prometheus.Registerer) (Compactor, Planner, error) { - // We don't need to customise the TSDB compactor so we're just using the Prometheus one. - compactor, err := tsdb.NewLeveledCompactor(ctx, reg, util_log.SlogFromGoKit(logger), cfg.BlockRanges.ToMilliseconds(), nil, nil) - if err != nil { - return nil, nil, err - } +func splitAndMergeCompactorFactory(ctx context.Context, cfg Config, cfgProvider ConfigProvider, logger log.Logger, reg prometheus.Registerer) (BlocksCompactorProvider, Planner, error) { + blockRanges := cfg.BlockRanges.ToMilliseconds() + + concurrencyOpts := tsdb.DefaultLeveledCompactorConcurrencyOptions() + concurrencyOpts.MaxOpeningBlocks = cfg.MaxOpeningBlocksConcurrency + concurrencyOpts.MaxClosingBlocks = cfg.MaxClosingBlocksConcurrency + concurrencyOpts.SymbolsFlushersCount = cfg.SymbolsFlushersConcurrency + + // The metrics are built once and shared by every compactor: registering them twice would panic, + // and they are aggregated across tenants anyway. + metrics := tsdb.NewCompactorMetrics(reg) - opts := tsdb.DefaultLeveledCompactorConcurrencyOptions() - opts.MaxOpeningBlocks = cfg.MaxOpeningBlocksConcurrency - opts.MaxClosingBlocks = cfg.MaxClosingBlocksConcurrency - opts.SymbolsFlushersCount = cfg.SymbolsFlushersConcurrency + // The encoding reaches the merge function through a callback taking no tenant, and jobs for + // different tenants compact concurrently, so we build one compactor per encoding. + compactors := make(map[chunkenc.Encoding]Compactor, len(validation.FloatChunkEncodingValues)) + for _, value := range validation.FloatChunkEncodingValues { + enc := validation.ParseFloatChunkEncoding(value) + compactor, err := tsdb.NewLeveledCompactorWithOptions(ctx, nil, util_log.SlogFromGoKit(logger), blockRanges, nil, tsdb.LeveledCompactorOptions{ + Metrics: metrics, + FloatChunkEncoding: func() chunkenc.Encoding { return enc }, + // Inert here, since Mimir plans compaction itself, but NewLeveledCompactor() set it + // and this keeps the switch to NewLeveledCompactorWithOptions() behaviour-preserving. + EnableOverlappingCompaction: true, + }) + if err != nil { + return nil, nil, fmt.Errorf("creating compactor for float chunk encoding %s: %w", value, err) + } - compactor.SetConcurrencyOptions(opts) + compactor.SetConcurrencyOptions(concurrencyOpts) + compactors[enc] = compactor + } + + // A downstream ConfigProvider may return an encoding the limit cannot select. + defaultCompactor := compactors[validation.ParseFloatChunkEncoding(validation.DefaultFloatChunkEncodingValue)] + provider := func(userID string) Compactor { + if compactor, ok := compactors[cfgProvider.FloatChunkEncoding(userID)]; ok { + return compactor + } + return defaultCompactor + } - planner := NewSplitAndMergePlanner(cfg.BlockRanges.ToMilliseconds()) - return compactor, planner, nil + return provider, NewSplitAndMergePlanner(blockRanges), nil } // configureSplitAndMergeCompactor updates the provided configuration injecting the split-and-merge compactor. diff --git a/pkg/compactor/split_merge_compactor_test.go b/pkg/compactor/split_merge_compactor_test.go index afb8e6891c7..52d244c9da4 100644 --- a/pkg/compactor/split_merge_compactor_test.go +++ b/pkg/compactor/split_merge_compactor_test.go @@ -6,6 +6,7 @@ import ( "context" "os" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -20,6 +21,7 @@ import ( "github.com/prometheus/client_golang/prometheus/testutil" "github.com/prometheus/prometheus/model/labels" "github.com/prometheus/prometheus/tsdb" + "github.com/prometheus/prometheus/tsdb/chunkenc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/thanos-io/objstore" @@ -30,6 +32,7 @@ import ( "github.com/grafana/mimir/pkg/storage/tsdb/block" util_log "github.com/grafana/mimir/pkg/util/log" util_test "github.com/grafana/mimir/pkg/util/test" + "github.com/grafana/mimir/pkg/util/validation" ) func TestMultitenantCompactor_ShouldSupportSplitAndMergeCompactor(t *testing.T) { @@ -624,6 +627,9 @@ func TestMultitenantCompactor_ShouldSupportSplitAndMergeCompactor(t *testing.T) cfgProvider := newMockConfigProvider() cfgProvider.splitAndMergeShards[userID] = testData.numShards + // Run the whole compactor for a tenant on a non-default encoding, so the lookup in + // newBucketCompactor() is exercised. + cfgProvider.floatChunkEncodings[userID] = chunkenc.EncXOR2 logger := log.NewLogfmtLogger(os.Stdout) reg := prometheus.NewPedanticRegistry() @@ -807,3 +813,146 @@ func convertMetasMapToSlice(metas map[ulid.ULID]*block.Meta) []*block.Meta { } return out } + +func TestSplitAndMergeCompactorFactory_ShouldRegisterTSDBCompactorMetricsOnce(t *testing.T) { + cfg := Config{} + flagext.DefaultValues(&cfg) + + reg := prometheus.NewPedanticRegistry() + provider, _, err := splitAndMergeCompactorFactory(t.Context(), cfg, newMockConfigProvider(), log.NewNopLogger(), reg) + require.NoError(t, err) + + require.NotNil(t, provider("user-1")) + + // The compactors share a single metrics instance: building the metrics per compactor would + // register them twice, which the pedantic registry would reject. Gathering also checks they are + // registered at all, so the test can't pass with them silently dropped instead. + require.NoError(t, testutil.GatherAndCompare(reg, strings.NewReader(` + # HELP prometheus_tsdb_compactions_total Total number of compactions that were executed for the partition. + # TYPE prometheus_tsdb_compactions_total counter + prometheus_tsdb_compactions_total 0 + `), "prometheus_tsdb_compactions_total")) +} + +// TestSplitAndMergeCompactorFactory_ShouldFailOnEmptyBlockRanges checks the factory fails at +// startup, rather than per compaction job. +func TestSplitAndMergeCompactorFactory_ShouldFailOnEmptyBlockRanges(t *testing.T) { + _, _, err := splitAndMergeCompactorFactory(t.Context(), Config{}, newMockConfigProvider(), log.NewNopLogger(), prometheus.NewRegistry()) + require.ErrorContains(t, err, "creating compactor for float chunk encoding") + require.ErrorContains(t, err, "at least one range must be provided") +} + +func TestSplitAndMergeCompactorFactory_VerticalCompactionHonorsFloatChunkEncoding(t *testing.T) { + tests := map[string]struct { + encoding chunkenc.Encoding + expectedEnc chunkenc.Encoding + }{ + "unset falls back to the default": {expectedEnc: chunkenc.EncXOR}, + "xor2": {encoding: chunkenc.EncXOR2, expectedEnc: chunkenc.EncXOR2}, + // A ConfigProvider is free to return an encoding the limit cannot select. + "encoding outside the limit falls back to the default": {encoding: chunkenc.EncHistogram, expectedEnc: chunkenc.EncXOR}, + } + + for testName, testData := range tests { + t.Run(testName, func(t *testing.T) { + const userID = "user-1" + + cfgProvider := newMockConfigProvider() + if testData.encoding != chunkenc.EncNone { + cfgProvider.floatChunkEncodings[userID] = testData.encoding + } + + cfg := Config{} + flagext.DefaultValues(&cfg) + + provider, _, err := splitAndMergeCompactorFactory(t.Context(), cfg, cfgProvider, log.NewNopLogger(), prometheus.NewRegistry()) + require.NoError(t, err) + + chunks := verticallyCompactOverlappingBlocks(t, provider(userID), t.TempDir()) + + // The two source blocks hold a single float chunk each, and they overlap, so the + // compactor has to merge them into one re-encoded chunk. Asserting the count keeps the + // assertion below from passing on a chunk that was copied over verbatim, since only + // re-encoded chunks get the configured encoding. + require.Len(t, chunks, 1) + assert.Equal(t, testData.expectedEnc, chunks[0].encoding) + + // The chunk must span both source blocks, which proves it is the merge of the two + // overlapping chunks and not one of them passed through. + assert.Less(t, chunks[0].minTime, verticallyCompactedBlocksOverlapStart) + assert.Greater(t, chunks[0].maxTime, verticallyCompactedBlocksOverlapStart) + }) + } +} + +// verticallyCompactedBlocksOverlapStart is the timestamp at which the two blocks created by +// verticallyCompactOverlappingBlocks() start overlapping. +const verticallyCompactedBlocksOverlapStart = int64(500) + +// floatChunkInfo describes a float chunk stored in a block. +type floatChunkInfo struct { + encoding chunkenc.Encoding + minTime int64 + maxTime int64 +} + +// verticallyCompactOverlappingBlocks compacts, in dir, two blocks holding the same series over +// overlapping time ranges, and returns the float chunks of the compacted block. Because the blocks +// overlap, the compactor has to merge and re-encode the float chunks, which is the only case where +// the configured float chunk encoding is applied. +func verticallyCompactOverlappingBlocks(t *testing.T, compactor Compactor, dir string) []floatChunkInfo { + t.Helper() + + // block.CreateBlock() cycles through value types, so out of these three series only the first + // one holds floats, while the other two hold histograms and float histograms. + series := []labels.Labels{ + labels.FromStrings("series", "1"), + labels.FromStrings("series", "2"), + labels.FromStrings("series", "3"), + } + + block1, err := block.CreateBlock(t.Context(), dir, series, 10, 0, 2*verticallyCompactedBlocksOverlapStart, labels.EmptyLabels()) + require.NoError(t, err) + block2, err := block.CreateBlock(t.Context(), dir, series, 10, verticallyCompactedBlocksOverlapStart, 3*verticallyCompactedBlocksOverlapStart, labels.EmptyLabels()) + require.NoError(t, err) + + compacted, err := compactor.Compact(dir, []string{filepath.Join(dir, block1.String()), filepath.Join(dir, block2.String())}, nil) + require.NoError(t, err) + require.Len(t, compacted, 1) + + return blockFloatChunks(t, filepath.Join(dir, compacted[0].String())) +} + +// blockFloatChunks returns every float chunk stored in the block at dir. +func blockFloatChunks(t *testing.T, dir string) []floatChunkInfo { + t.Helper() + + b, err := tsdb.OpenBlock(nil, dir, nil, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, b.Close()) }) + + q, err := tsdb.NewBlockChunkQuerier(b, b.MinTime(), b.MaxTime()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, q.Close()) }) + + floatEncodings := make([]chunkenc.Encoding, 0, len(validation.FloatChunkEncodingValues)) + for _, value := range validation.FloatChunkEncodingValues { + floatEncodings = append(floatEncodings, validation.ParseFloatChunkEncoding(value)) + } + + var chunks []floatChunkInfo + ss := q.Select(t.Context(), true, nil, labels.MustNewMatcher(labels.MatchRegexp, "series", ".*")) + for ss.Next() { + it := ss.At().Iterator(nil) + for it.Next() { + meta := it.At() + if slices.Contains(floatEncodings, meta.Chunk.Encoding()) { + chunks = append(chunks, floatChunkInfo{encoding: meta.Chunk.Encoding(), minTime: meta.MinTime, maxTime: meta.MaxTime}) + } + } + require.NoError(t, it.Err()) + } + require.NoError(t, ss.Err()) + + return chunks +} diff --git a/pkg/ingester/ingester_test.go b/pkg/ingester/ingester_test.go index 41fa9dd680c..9e51b392af0 100644 --- a/pkg/ingester/ingester_test.go +++ b/pkg/ingester/ingester_test.go @@ -12511,49 +12511,72 @@ func testIngesterXOR2Encoding(t *testing.T, xor2Enabled bool) { verifyChunkSample(t, expectedChunkEnc, chunks[0].Data, ts, 42) } +// TestIngesterXOR2EncodingRuntimeToggle covers changing the float_chunk_encoding limit at runtime, +// in both directions. Both directions already worked; this guards the normalisation in +// applyTSDBSettings() that clearing the limit depends on. func TestIngesterXOR2EncodingRuntimeToggle(t *testing.T) { - userID := "user1" - tenantOverride := new(TenantLimitsMock) - tenantOverride.On("ByUserID", userID).Return(nil) - - limits := defaultLimitsTestConfig() - override := validation.NewOverrides(limits, tenantOverride) + tests := map[string]struct { + initialLimit string + updatedLimit string + expectedBefore chunk.Encoding + expectedAfter chunk.Encoding + }{ + "enabling XOR2": { + initialLimit: "", updatedLimit: "xor2", + expectedBefore: chunk.PrometheusXorChunk, expectedAfter: chunk.PrometheusXor2Chunk, + }, + "clearing the limit falls back to XOR": { + initialLimit: "xor2", updatedLimit: "", + expectedBefore: chunk.PrometheusXor2Chunk, expectedAfter: chunk.PrometheusXorChunk, + }, + } - cfg := defaultIngesterTestConfig(t) - cfg.TSDBConfigUpdatePeriod = 1 * time.Second - i, r, err := prepareIngesterWithBlockStorageAndOverrides(t, cfg, override, nil, "", "", prometheus.NewRegistry()) - require.NoError(t, err) - startAndWaitHealthy(t, i, r) + for testName, testData := range tests { + t.Run(testName, func(t *testing.T) { + userID := "user1" + tenantOverride := new(TenantLimitsMock) + tenantOverride.On("ByUserID", userID).Return(&validation.Limits{FloatChunkEncoding: testData.initialLimit}) - ctx := user.InjectOrgID(context.Background(), userID) + override := validation.NewOverrides(defaultLimitsTestConfig(), tenantOverride) - _, err = i.Push(ctx, mimirpb.ToWriteRequest( - [][]mimirpb.LabelAdapter{{{Name: model.MetricNameLabel, Value: "testmetric_xor2_before"}}}, - []mimirpb.Sample{{TimestampMs: 1000, Value: 1}}, - nil, nil, mimirpb.API, - )) - require.NoError(t, err) + cfg := defaultIngesterTestConfig(t) + cfg.TSDBConfigUpdatePeriod = 1 * time.Second + i, r, err := prepareIngesterWithBlockStorageAndOverrides(t, cfg, override, nil, "", "", prometheus.NewRegistry()) + require.NoError(t, err) + startAndWaitHealthy(t, i, r) - chunks := queryXOR2ChunksForMetric(ctx, t, i, "testmetric_xor2_before") - require.Len(t, chunks, 1) - assert.Equal(t, int32(chunk.PrometheusXorChunk), chunks[0].Encoding) + ctx := user.InjectOrgID(context.Background(), userID) - // Enable XOR2 at runtime. - tenantOverride.ExpectedCalls = nil - tenantOverride.On("ByUserID", userID).Return(&validation.Limits{FloatChunkEncoding: "xor2"}) - <-time.After(1500 * time.Millisecond) + // This push opens the tenant's TSDB, seeding its startup encoding from the limit. + _, err = i.Push(ctx, mimirpb.ToWriteRequest( + [][]mimirpb.LabelAdapter{{{Name: model.MetricNameLabel, Value: "testmetric_xor2_before"}}}, + []mimirpb.Sample{{TimestampMs: 1000, Value: 1}}, + nil, nil, mimirpb.API, + )) + require.NoError(t, err) - // A new series always starts a fresh chunk, which will use the updated XOR2 setting. - _, err = i.Push(ctx, mimirpb.ToWriteRequest( - [][]mimirpb.LabelAdapter{{{Name: model.MetricNameLabel, Value: "testmetric_xor2_after"}}}, - []mimirpb.Sample{{TimestampMs: 2000, Value: 2}}, - nil, nil, mimirpb.API, - )) - require.NoError(t, err) + chunks := queryXOR2ChunksForMetric(ctx, t, i, "testmetric_xor2_before") + require.Len(t, chunks, 1) + assert.Equal(t, int32(testData.expectedBefore), chunks[0].Encoding) + + // Change the limit at runtime. + tenantOverride.ExpectedCalls = nil + tenantOverride.On("ByUserID", userID).Return(&validation.Limits{FloatChunkEncoding: testData.updatedLimit}) + <-time.After(1500 * time.Millisecond) + + // A new series always starts a fresh chunk, which will use the updated setting. + _, err = i.Push(ctx, mimirpb.ToWriteRequest( + [][]mimirpb.LabelAdapter{{{Name: model.MetricNameLabel, Value: "testmetric_xor2_after"}}}, + []mimirpb.Sample{{TimestampMs: 2000, Value: 2}}, + nil, nil, mimirpb.API, + )) + require.NoError(t, err) - chunks = queryXOR2ChunksForMetric(ctx, t, i, "testmetric_xor2_after") - require.Len(t, chunks, 1) - assert.Equal(t, int32(chunk.PrometheusXor2Chunk), chunks[0].Encoding) + chunks = queryXOR2ChunksForMetric(ctx, t, i, "testmetric_xor2_after") + require.Len(t, chunks, 1) + assert.Equal(t, int32(testData.expectedAfter), chunks[0].Encoding) + }) + } } func queryXOR2Chunks(ctx context.Context, t *testing.T, i *Ingester) []client.Chunk { diff --git a/pkg/ingester/ingester_tsdb.go b/pkg/ingester/ingester_tsdb.go index 589e9953f6a..ab97af867d1 100644 --- a/pkg/ingester/ingester_tsdb.go +++ b/pkg/ingester/ingester_tsdb.go @@ -25,7 +25,6 @@ import ( promcfg "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/storage" "github.com/prometheus/prometheus/tsdb" - "github.com/prometheus/prometheus/tsdb/chunkenc" "go.uber.org/atomic" "golang.org/x/sync/errgroup" @@ -62,7 +61,10 @@ func (i *Ingester) applyTSDBSettings() { TSDBConfig: &promcfg.TSDBConfig{ OutOfOrderTimeWindow: oooTW.Milliseconds(), ChunkEncoding: promcfg.ChunkEncodingConfig{ - Floats: floatChunkEncodingConfig(i.limits.FloatChunkEncoding(userID)), + // ApplyConfig() reads the empty string as "keep the encoding resolved at + // startup", so a tenant clearing the limit needs an explicit value here to + // fall back to the default rather than keep its old encoding. + Floats: i.limits.FloatChunkEncodingValue(userID), }, }, }, @@ -77,15 +79,6 @@ func (i *Ingester) applyTSDBSettings() { } } -// floatChunkEncodingConfig normalizes a per-tenant encoding into a value accepted -// by chunk_encoding.floats, defaulting to XOR for unknown values. -func floatChunkEncodingConfig(enc chunkenc.Encoding) string { - if enc == chunkenc.EncXOR2 { - return promcfg.FloatChunkEncodingXOR2 - } - return promcfg.FloatChunkEncodingXOR -} - func (i *Ingester) getTSDB(userID string) *userTSDB { i.tsdbsMtx.RLock() defer i.tsdbsMtx.RUnlock() diff --git a/pkg/util/validation/exporter/exporter.go b/pkg/util/validation/exporter/exporter.go index 4a04123e824..835b0307e7c 100644 --- a/pkg/util/validation/exporter/exporter.go +++ b/pkg/util/validation/exporter/exporter.go @@ -21,7 +21,7 @@ import ( "github.com/grafana/dskit/services" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" - promcfg "github.com/prometheus/prometheus/config" + "github.com/prometheus/prometheus/tsdb/chunkenc" "github.com/grafana/mimir/pkg/storage/chunk" "github.com/grafana/mimir/pkg/util" @@ -85,7 +85,7 @@ var stringLimitMetricGetters = map[string]func(*validation.Limits) float64{ // stable numeric value. The values match the storage chunk encoding constants, which // are hardcoded for backward compatibility, making them a safe metric contract. func floatChunkEncodingMetricValue(limits *validation.Limits) float64 { - if limits.FloatChunkEncoding == promcfg.FloatChunkEncodingXOR2 { + if validation.ParseFloatChunkEncoding(limits.FloatChunkEncoding) == chunkenc.EncXOR2 { return float64(chunk.PrometheusXor2Chunk) } return float64(chunk.PrometheusXorChunk) diff --git a/pkg/util/validation/exporter/exporter_test.go b/pkg/util/validation/exporter/exporter_test.go index 32e2d520504..52fa56907ae 100644 --- a/pkg/util/validation/exporter/exporter_test.go +++ b/pkg/util/validation/exporter/exporter_test.go @@ -444,6 +444,14 @@ cortex_limits_defaults{limit_name="float_chunk_encoding"} 4 require.NoError(t, testutil.CollectAndCompare(exporter, bytes.NewBufferString(expectedDefaults), "cortex_limits_defaults")) } +// TestFloatChunkEncodingMetricValue asserts the numeric values the metric reports, which are a +// contract with dashboards. An encoding added to the limit without a value here reports as xor. +func TestFloatChunkEncodingMetricValue(t *testing.T) { + assert.Equal(t, float64(4), floatChunkEncodingMetricValue(&validation.Limits{FloatChunkEncoding: "xor"})) + assert.Equal(t, float64(7), floatChunkEncodingMetricValue(&validation.Limits{FloatChunkEncoding: "xor2"})) + assert.Len(t, validation.FloatChunkEncodingValues, 2, "an encoding was added to the limit, give it a metric value") +} + func TestConfig_Validate_floatChunkEncoding(t *testing.T) { // float_chunk_encoding is a string limit, but it is exportable through a dedicated getter. cfg := Config{EnabledMetrics: []string{floatChunkEncoding}} diff --git a/pkg/util/validation/limits.go b/pkg/util/validation/limits.go index d22ee458dc7..31e15a8bc0a 100644 --- a/pkg/util/validation/limits.go +++ b/pkg/util/validation/limits.go @@ -12,6 +12,7 @@ import ( "flag" "fmt" "hash/fnv" + "maps" "math" "reflect" "slices" @@ -93,7 +94,7 @@ var ( errInvalidMaxEstimatedChunksPerQueryMultiplier = fmt.Errorf("invalid value for -%s: must be 0 or greater than or equal to 1", MaxEstimatedChunksPerQueryMultiplierFlag) errNegativeUpdateTimeoutJitterMax = errors.New("HA tracker max update timeout jitter shouldn't be negative") errNegativeMaxBlocksPerStoreRequest = fmt.Errorf("-%s must be 0 or greater", MaxBlocksPerStoreRequestFlag) - errInvalidFloatChunkEncoding = fmt.Errorf("invalid float chunk encoding (supported values: %q, %q)", promcfg.FloatChunkEncodingXOR, promcfg.FloatChunkEncodingXOR2) + errInvalidFloatChunkEncoding = fmt.Errorf("invalid float chunk encoding (supported values: %s)", strings.Join(FloatChunkEncodingValues, ", ")) ) const ( @@ -101,6 +102,31 @@ const ( errLabelValueHashExceedsLimit = "cannot set -" + LabelValueLengthOverLimitStrategyFlag + " to %q: label value hash suffix would exceed max label value length of %d" ) +// DefaultFloatChunkEncodingValue is the value of the -ingester.float-chunk-encoding limit used when the +// limit is unset, or holds a value this version does not support. +const DefaultFloatChunkEncodingValue = promcfg.FloatChunkEncodingXOR + +// floatChunkEncodings maps every value the -ingester.float-chunk-encoding limit accepts to the chunk +// encoding it selects. +var floatChunkEncodings = map[string]chunkenc.Encoding{ + DefaultFloatChunkEncodingValue: chunkenc.EncXOR, + promcfg.FloatChunkEncodingXOR2: chunkenc.EncXOR2, +} + +// FloatChunkEncodingValues holds the values the -ingester.float-chunk-encoding limit accepts, +// sorted, so that the flag help and the validation error list them in a stable order. +var FloatChunkEncodingValues = slices.Sorted(maps.Keys(floatChunkEncodings)) + +// ParseFloatChunkEncoding returns the chunk encoding selected by the given value of the +// -ingester.float-chunk-encoding limit. A value the limit does not accept, the empty string +// included, selects the encoding of DefaultFloatChunkEncodingValue. +func ParseFloatChunkEncoding(value string) chunkenc.Encoding { + if enc, ok := floatChunkEncodings[value]; ok { + return enc + } + return floatChunkEncodings[DefaultFloatChunkEncodingValue] +} + // LimitError is a marker interface for the errors that do not comply with the specified limits. type LimitError interface { error @@ -418,7 +444,7 @@ func (l *Limits) RegisterFlags(f *flag.FlagSet) { f.Var(&l.ActiveSeriesBaseCustomTrackersConfig, "ingester.active-series-custom-trackers", "Additional active series metrics, matching the provided matchers. Matchers should be in form :, like 'foobar:{foo=\"bar\"}'. Multiple matchers can be provided either providing the flag multiple times or providing multiple semicolon-separated values to a single flag.") f.Var(&l.OutOfOrderTimeWindow, OutOfOrderTimeWindowFlag, fmt.Sprintf("Non-zero value enables out-of-order support for most recent samples that are within the time window in relation to the TSDB's maximum time, i.e., within [db.maxTime-timeWindow, db.maxTime]). The ingester will need more memory as a factor of rate of out-of-order samples being ingested and the number of series that are getting out-of-order samples. If query falls into this window, cached results will use value from -%s option to specify TTL for resulting cache entry.", resultsCacheTTLForOutOfOrderWindowFlag)) f.BoolVar(&l.NativeHistogramsIngestionEnabled, "ingester.native-histograms-ingestion-enabled", true, "Enable ingestion of native histogram samples. If false, native histogram samples are ignored without an error. To query native histograms with query-sharding enabled make sure to set -query-frontend.query-result-response-format to 'protobuf'.") - f.StringVar(&l.FloatChunkEncoding, "ingester.float-chunk-encoding", "xor", "Encoding used for float chunks in the ingester and block builder for this tenant. Valid values are 'xor' and 'xor2'.") + f.StringVar(&l.FloatChunkEncoding, "ingester.float-chunk-encoding", DefaultFloatChunkEncodingValue, fmt.Sprintf("Encoding used for float chunks written for this tenant by the ingester and block-builder, and by the compactor when it re-encodes overlapping chunks. Supported values are: %s.", strings.Join(FloatChunkEncodingValues, ", "))) f.BoolVar(&l.OutOfOrderBlocksExternalLabelEnabled, "ingester.out-of-order-blocks-external-label-enabled", false, "Whether the shipper should label out-of-order blocks with an external label before uploading them. Setting this label will compact out-of-order blocks separately from non-out-of-order blocks") f.IntVar(&l.EarlyHeadCompactionOwnedSeriesThreshold, "ingester.early-head-compaction-owned-series-threshold", 0, "When the number of owned series for a tenant across the cluster exceeds this threshold, trigger early head compaction. 0 to disable.") f.IntVar(&l.EarlyHeadCompactionMinEstimatedSeriesReductionPercentage, "ingester.early-head-compaction-min-estimated-series-reduction-percentage", 15, "Minimum estimated series reduction percentage (0-100) required to trigger per-tenant early compaction.") @@ -733,9 +759,7 @@ func (l *Limits) Validate() error { return errNegativeMaxBlocksPerStoreRequest } - switch l.FloatChunkEncoding { - case "", promcfg.FloatChunkEncodingXOR, promcfg.FloatChunkEncodingXOR2: - default: + if l.FloatChunkEncoding != "" && !slices.Contains(FloatChunkEncodingValues, l.FloatChunkEncoding) { return errInvalidFloatChunkEncoding } @@ -1422,12 +1446,21 @@ func (o *Overrides) NativeHistogramsIngestionEnabled(userID string) bool { return o.getOverridesForUser(userID).NativeHistogramsIngestionEnabled } -// FloatChunkEncoding returns the float chunk encoding for this tenant, defaulting to XOR for unknown values. +// FloatChunkEncoding returns the float chunk encoding for this tenant. func (o *Overrides) FloatChunkEncoding(userID string) chunkenc.Encoding { - if o.getOverridesForUser(userID).FloatChunkEncoding == promcfg.FloatChunkEncodingXOR2 { - return chunkenc.EncXOR2 + return ParseFloatChunkEncoding(o.getOverridesForUser(userID).FloatChunkEncoding) +} + +// FloatChunkEncodingValue returns the float chunk encoding for this tenant as a value of the +// -ingester.float-chunk-encoding limit, which is never empty: tsdb.DB.ApplyConfig() reads an empty +// chunk encoding as "keep the encoding resolved at startup", so a tenant that clears the limit has +// to be handed DefaultFloatChunkEncodingValue explicitly to fall back to it. +func (o *Overrides) FloatChunkEncodingValue(userID string) string { + value := o.getOverridesForUser(userID).FloatChunkEncoding + if _, ok := floatChunkEncodings[value]; ok { + return value } - return chunkenc.EncXOR + return DefaultFloatChunkEncodingValue } func (o *Overrides) MaxExemplarsPerSeriesPerRequest(userID string) int { diff --git a/pkg/util/validation/limits_test.go b/pkg/util/validation/limits_test.go index 8e9405b3e74..73c9251fd11 100644 --- a/pkg/util/validation/limits_test.go +++ b/pkg/util/validation/limits_test.go @@ -1659,6 +1659,16 @@ func TestLimits_Validate(t *testing.T) { }(), expectedErr: nil, }, + "should pass if float_chunk_encoding is empty": { + cfg: func() Limits { + cfg := Limits{} + flagext.DefaultValues(&cfg) + cfg.FloatChunkEncoding = "" + + return cfg + }(), + expectedErr: nil, + }, "should pass if otel_translation_strategy is UnderscoreEscapingWithoutSuffixes and name_validation_scheme is legacy and metric name suffixes are disabled": { cfg: func() Limits { cfg := Limits{} @@ -3068,17 +3078,63 @@ func TestMergeLimits(t *testing.T) { } func TestOverrides_FloatChunkEncoding(t *testing.T) { - t.Run("default is xor", func(t *testing.T) { - overrides := MockOverrides(nil) - assert.Equal(t, chunkenc.EncXOR, overrides.FloatChunkEncoding("user1")) + overrides := MockOverrides(func(_ *Limits, tenantLimits map[string]*Limits) { + tenantLimits["user1"] = &Limits{FloatChunkEncoding: "xor2"} }) - t.Run("per-tenant override to xor2", func(t *testing.T) { - overrides := MockOverrides(func(_ *Limits, tenantLimits map[string]*Limits) { - tenantLimits["user1"] = &Limits{FloatChunkEncoding: "xor2"} + + assert.Equal(t, chunkenc.EncXOR2, overrides.FloatChunkEncoding("user1")) + + // A tenant without an override gets the default encoding. + assert.Equal(t, chunkenc.EncXOR, overrides.FloatChunkEncoding("user2")) +} + +func TestFloatChunkEncodingValues(t *testing.T) { + assert.Equal(t, []string{"xor", "xor2"}, FloatChunkEncodingValues) + + seen := map[chunkenc.Encoding]string{} + for _, value := range FloatChunkEncodingValues { + limits := Limits{} + flagext.DefaultValues(&limits) + limits.FloatChunkEncoding = value + assert.NoError(t, limits.Validate(), "value %s", value) + + enc := ParseFloatChunkEncoding(value) + assert.NotContains(t, seen, enc, "values %s and %s both select %s", seen[enc], value, enc) + seen[enc] = value + } +} + +func TestParseFloatChunkEncoding(t *testing.T) { + tests := map[string]struct { + value string + expected chunkenc.Encoding + }{ + "empty selects the default": {value: "", expected: chunkenc.EncXOR}, + "xor": {value: "xor", expected: chunkenc.EncXOR}, + "xor2": {value: "xor2", expected: chunkenc.EncXOR2}, + "uppercase is not accepted": {value: "XOR2", expected: chunkenc.EncXOR}, + "histogram is not a float chunk encoding": {value: "histogram", expected: chunkenc.EncXOR}, + } + + for name, testData := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, testData.expected, ParseFloatChunkEncoding(testData.value)) }) - assert.Equal(t, chunkenc.EncXOR2, overrides.FloatChunkEncoding("user1")) - assert.Equal(t, chunkenc.EncXOR, overrides.FloatChunkEncoding("user2")) + } +} + +func TestOverrides_FloatChunkEncodingValue(t *testing.T) { + overrides := MockOverrides(func(_ *Limits, tenantLimits map[string]*Limits) { + tenantLimits["user1"] = &Limits{FloatChunkEncoding: "xor2"} + tenantLimits["user2"] = &Limits{FloatChunkEncoding: ""} + tenantLimits["user3"] = &Limits{FloatChunkEncoding: "nope"} }) + + assert.Equal(t, "xor2", overrides.FloatChunkEncodingValue("user1")) + + // Never the empty string: ApplyConfig() would read it as "keep the startup encoding". + assert.Equal(t, "xor", overrides.FloatChunkEncodingValue("user2")) + assert.Equal(t, "xor", overrides.FloatChunkEncodingValue("user3")) } func boolPtr(b bool) *bool { diff --git a/tools/compaction-planner/main.go b/tools/compaction-planner/main.go index a7fc64af43e..dd418e999a3 100644 --- a/tools/compaction-planner/main.go +++ b/tools/compaction-planner/main.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/dskit/flagext" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/prometheus/model/timestamp" + "github.com/prometheus/prometheus/tsdb/chunkenc" "github.com/grafana/mimir/pkg/compactor" "github.com/grafana/mimir/pkg/storage/bucket" @@ -25,6 +26,7 @@ import ( "github.com/grafana/mimir/pkg/storage/tsdb/block" "github.com/grafana/mimir/pkg/storage/tsdb/bucketindex" "github.com/grafana/mimir/pkg/util/extprom" + "github.com/grafana/mimir/pkg/util/validation" ) func main() { @@ -154,3 +156,6 @@ func (c *staticConfigProvider) CompactorMaxPerBlockUploadConcurrency(_ string) i func (c *staticConfigProvider) S3SSEType(_ string) string { return "" } func (c *staticConfigProvider) S3SSEKMSKeyID(_ string) string { return "" } func (c *staticConfigProvider) S3SSEKMSEncryptionContext(_ string) string { return "" } +func (c *staticConfigProvider) FloatChunkEncoding(_ string) chunkenc.Encoding { + return validation.ParseFloatChunkEncoding(validation.DefaultFloatChunkEncodingValue) +}