From 924b77295f468d9bbc8c4ad0d3fa432cee936895 Mon Sep 17 00:00:00 2001 From: Bisshwajit Samanta Date: Tue, 11 Aug 2026 16:09:41 +0530 Subject: [PATCH 1/6] receive: fallback empty tenant_matcher_type to exact match Signed-off-by: Bisshwajit Samanta --- pkg/receive/hashring.go | 2 +- pkg/receive/hashring_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/receive/hashring.go b/pkg/receive/hashring.go index 7ceb02a4f06..b3ebeb3bc3a 100644 --- a/pkg/receive/hashring.go +++ b/pkg/receive/hashring.go @@ -526,7 +526,7 @@ func (s *shuffleShardHashring) dedupedNodes() []Endpoint { func (s *shuffleShardHashring) getShardSize(tenant string) int { for _, override := range s.shuffleShardingConfig.Overrides { switch override.TenantMatcherType { - case TenantMatcherTypeExact: + case TenantMatcherTypeExact, "": if slices.Contains(override.Tenants, tenant) { return override.ShardSize } diff --git a/pkg/receive/hashring_test.go b/pkg/receive/hashring_test.go index 288dde89870..9db8ced8f55 100644 --- a/pkg/receive/hashring_test.go +++ b/pkg/receive/hashring_test.go @@ -713,7 +713,7 @@ func TestShuffleShardHashring(t *testing.T) { Overrides: []ShuffleShardingOverrideConfig{ { Tenants: []string{"special-tenant"}, - ShardSize: 2, + ShardSize: 3, }, }, }, From 92b53a31407c957eb24935a40a35bb6f499f0d8a Mon Sep 17 00:00:00 2001 From: Bisshwajit Samanta Date: Thu, 13 Aug 2026 11:09:09 +0530 Subject: [PATCH 2/6] receive: strict validation and normalization for tenantMatcher - Add custom UnmarshalJSON for tenantMatcher to fail fast on invalid types and explicit empty strings - Hydrate omitted tenant_matcher_type fields to 'exact' in ParseConfig - Add table-driven tests for tenantMatcher unmarshaling Signed-off-by: Bisshwajit Samanta --- pkg/receive/config.go | 38 ++++++++++++++++++++++++++++- pkg/receive/hashring_test.go | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/pkg/receive/config.go b/pkg/receive/config.go index e1b9754757b..3ae65e93c25 100644 --- a/pkg/receive/config.go +++ b/pkg/receive/config.go @@ -153,6 +153,30 @@ const ( TenantMatcherGlob tenantMatcher = "glob" ) +// UnmarshalJSON implements the json.Unmarshaler interface for tenantMatcher. +// We override the default unmarshaling to provide parse-time validation, +// ensuring that only valid matchers ("exact", "glob", or "") are accepted. +// This allows Thanos to fail fast during configuration loading if a user +// provides an invalid matcher type, rather than silently falling back at runtime. +func (t *tenantMatcher) UnmarshalJSON(data []byte) error { + // Convert Raw Json bytes into a string + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + + // Check if that string is valid + switch str { + case "exact": + *t = TenantMatcherTypeExact + case "glob": + *t = tenantMatcher(str) + default: + return fmt.Errorf("invalid tenant matcher type: %s", str) + } + return nil +} + func isExactMatcher(m tenantMatcher) bool { return m == TenantMatcherTypeExact || m == "" } @@ -387,7 +411,19 @@ func ConfigFromWatcher(ctx context.Context, updates chan<- []HashringConfig, cw func ParseConfig(content []byte) ([]HashringConfig, error) { var config []HashringConfig err := json.Unmarshal(content, &config) - return config, err + if err != nil { + return nil, err + } + + // Post-Parsing Normalization Pass + // If the user completely omitted the TenantMatcherType field, Go leaves it as "". + // We must explicitly default these omitted fields to "exact" to prevent routing failures. + for i := range config { + if config[i].TenantMatcherType == "" { + config[i].TenantMatcherType = TenantMatcherTypeExact + } + } + return config, nil } // loadConfig loads raw configuration content and returns a configuration. diff --git a/pkg/receive/hashring_test.go b/pkg/receive/hashring_test.go index 9db8ced8f55..29d7b9a4d0f 100644 --- a/pkg/receive/hashring_test.go +++ b/pkg/receive/hashring_test.go @@ -927,6 +927,53 @@ func assignReplicatedSeries(series []prompb.TimeSeries, nodes []Endpoint, replic return assignments, nil } +func TestTenantMatcher_UnmarshalJSON(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + input []byte + expected tenantMatcher + expectError bool + }{ + { + name: "Invalid garbage matcher", + input: []byte(`"exakt"`), + expected: "", + expectError: true, + }, + { + name: "Valid exact matcher", + input: []byte(`"exact"`), + expected: TenantMatcherTypeExact, + expectError: false, + }, + { + name: "Valid glob matcher", + input: []byte(`"glob"`), + expected: TenantMatcherGlob, + expectError: false, + }, + { + name: "Invalid empty matcher (explicitly rejected)", + input: []byte(`""`), + expected: "", + expectError: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var tm tenantMatcher + err := tm.UnmarshalJSON(tc.input) + if (err != nil) != tc.expectError { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tc.expectError) + } + if tm != tc.expected { + t.Errorf("UnmarshalJSON() got = %v, want %v", tm, tc.expected) + } + }) + } +} + // TestShuffleShardHashringStability tests that shuffle sharding is stable when // adding/removing nodes. When scaling from N to N+1 nodes, at most 1 node should // change in a tenant's shard (the "consistency" property). From 5a53d0a2a15a9b87264366473aca68fcf342f116 Mon Sep 17 00:00:00 2001 From: Bisshwajit Samanta Date: Thu, 13 Aug 2026 11:20:54 +0530 Subject: [PATCH 3/6] receive: clean up tenant matcher routing logic - Remove redundant empty string case from getShardSize since ParseConfig hydrates omitted fields to exact - Rely strictly on TenantMatcherTypeExact and TenantMatcherGlob at runtime Signed-off-by: Bisshwajit Samanta --- pkg/receive/hashring.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/receive/hashring.go b/pkg/receive/hashring.go index b3ebeb3bc3a..7ceb02a4f06 100644 --- a/pkg/receive/hashring.go +++ b/pkg/receive/hashring.go @@ -526,7 +526,7 @@ func (s *shuffleShardHashring) dedupedNodes() []Endpoint { func (s *shuffleShardHashring) getShardSize(tenant string) int { for _, override := range s.shuffleShardingConfig.Overrides { switch override.TenantMatcherType { - case TenantMatcherTypeExact, "": + case TenantMatcherTypeExact: if slices.Contains(override.Tenants, tenant) { return override.ShardSize } From 350cf54108f249c5d01d64c91eca5e591a0c9550 Mon Sep 17 00:00:00 2001 From: Bisshwajit Samanta Date: Thu, 13 Aug 2026 18:35:51 +0530 Subject: [PATCH 4/6] receive: hydrate omitted tenant matcher type during config parsing Signed-off-by: Bisshwajit Samanta --- pkg/receive/config.go | 5 +++ pkg/receive/config_test.go | 75 ++++++++++++++++++++++++++++++++++++ pkg/receive/hashring_test.go | 2 +- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/pkg/receive/config.go b/pkg/receive/config.go index 3ae65e93c25..b46cde85a4e 100644 --- a/pkg/receive/config.go +++ b/pkg/receive/config.go @@ -422,6 +422,11 @@ func ParseConfig(content []byte) ([]HashringConfig, error) { if config[i].TenantMatcherType == "" { config[i].TenantMatcherType = TenantMatcherTypeExact } + for j := range config[i].ShuffleShardingConfig.Overrides { + if config[i].ShuffleShardingConfig.Overrides[j].TenantMatcherType == "" { + config[i].ShuffleShardingConfig.Overrides[j].TenantMatcherType = TenantMatcherTypeExact + } + } } return config, nil } diff --git a/pkg/receive/config_test.go b/pkg/receive/config_test.go index fc7ef76118d..9737bfaea17 100644 --- a/pkg/receive/config_test.go +++ b/pkg/receive/config_test.go @@ -74,6 +74,81 @@ func TestValidateConfig(t *testing.T) { } } +// TestParseConfig verifies that ParseConfig successfully unmarshals raw JSON +// configuration and executes a post-parsing normalization pass. Omitted +// "tenant_matcher_type" fields (both top-level and inside overrides) must +// automatically hydrate to "exact" to prevent downstream routing bugs. +func TestParseConfig(t *testing.T) { + // JSON payload testing three distinct cases: + // 1. Explicit "exact" matcher (should remain unchanged) + // 2. Missing "tenant_matcher_type" (should trigger hydration to "exact") + // 3. Explicit "glob" matcher (should preserve non-default matchers) + inputJSON := []byte(`[ + { + "hashring": "test-ring", + "endpoints": ["node-1"], + "shuffle_sharding_config": { + "shard_size": 2, + "overrides": [ + { + "shard_size": 3, + "tenants": ["tenant-1"], + "tenant_matcher_type": "exact" + }, + { + "shard_size": 4, + "tenants": ["tenant-2"] + }, + { + "shard_size": 5, + "tenants": ["tenant-3"], + "tenant_matcher_type": "glob" + } + ] + } + } +]`) + configs, err := ParseConfig(inputJSON) + testutil.Ok(t, err) + // Assert that ParseConfig returns fully normalized data: + // - Top-level TenantMatcherType is hydrated to TenantMatcherTypeExact. + // - Omitted override matcher ("tenant-2") is hydrated to TenantMatcherTypeExact. + // - Explicit matchers ("tenant-1", "tenant-3") retain their exact values. + expected := []HashringConfig{ + { + Hashring: "test-ring", + TenantMatcherType: TenantMatcherTypeExact, + Endpoints: []Endpoint{ + { + Address: "node-1", + CapNProtoAddress: "node-1:19391", + }, + }, + ShuffleShardingConfig: ShuffleShardingConfig{ + ShardSize: 2, + Overrides: []ShuffleShardingOverrideConfig{ + { + ShardSize: 3, + Tenants: []string{"tenant-1"}, + TenantMatcherType: TenantMatcherTypeExact, + }, + { + ShardSize: 4, + Tenants: []string{"tenant-2"}, + TenantMatcherType: TenantMatcherTypeExact, + }, + { + ShardSize: 5, + Tenants: []string{"tenant-3"}, + TenantMatcherType: TenantMatcherGlob, + }, + }, + }, + }, + } + testutil.Equals(t, expected, configs) +} + func TestUnmarshalEndpointSlice(t *testing.T) { t.Parallel() diff --git a/pkg/receive/hashring_test.go b/pkg/receive/hashring_test.go index 29d7b9a4d0f..7b7f7ff792b 100644 --- a/pkg/receive/hashring_test.go +++ b/pkg/receive/hashring_test.go @@ -713,7 +713,7 @@ func TestShuffleShardHashring(t *testing.T) { Overrides: []ShuffleShardingOverrideConfig{ { Tenants: []string{"special-tenant"}, - ShardSize: 3, + ShardSize: 3, // Override Value changed }, }, }, From 96f0415ed35768b8b394b196a1750eb7ef1ee1f2 Mon Sep 17 00:00:00 2001 From: Bisshwajit Samanta Date: Thu, 13 Aug 2026 18:43:09 +0530 Subject: [PATCH 5/6] receive: document validation boundary in tenantMatcher tests Signed-off-by: Bisshwajit Samanta --- pkg/receive/hashring_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/receive/hashring_test.go b/pkg/receive/hashring_test.go index 7b7f7ff792b..b0632c7c128 100644 --- a/pkg/receive/hashring_test.go +++ b/pkg/receive/hashring_test.go @@ -927,8 +927,13 @@ func assignReplicatedSeries(series []prompb.TimeSeries, nodes []Endpoint, replic return assignments, nil } +// TestTenantMatcher_UnmarshalJSON verifies that the custom JSON unmarshaling +// logic for the tenantMatcher type strictly validates incoming matcher strings. +// It ensures supported types ("exact", "glob") are correctly parsed, while +// unrecognized strings and explicit empty strings ("") are rejected immediately. func TestTenantMatcher_UnmarshalJSON(t *testing.T) { t.Parallel() + // Table-driven test cases covering valid, malformed, and edge-case inputs: for _, tc := range []struct { name string input []byte @@ -936,24 +941,30 @@ func TestTenantMatcher_UnmarshalJSON(t *testing.T) { expectError bool }{ { + // Unrecognized matcher strings must fail validation at parse time. name: "Invalid garbage matcher", input: []byte(`"exakt"`), expected: "", expectError: true, }, { + // Explicit "exact" matcher unmarshals successfully. name: "Valid exact matcher", input: []byte(`"exact"`), expected: TenantMatcherTypeExact, expectError: false, }, { + // Explicit "glob" matcher unmarshals successfully. name: "Valid glob matcher", input: []byte(`"glob"`), expected: TenantMatcherGlob, expectError: false, }, { + // Explicitly providing "" in JSON must be rejected by UnmarshalJSON. + // Defaulting omitted fields to "exact" is handled during post-parse + // normalization, NOT by accepting raw empty JSON strings. name: "Invalid empty matcher (explicitly rejected)", input: []byte(`""`), expected: "", @@ -964,9 +975,11 @@ func TestTenantMatcher_UnmarshalJSON(t *testing.T) { t.Parallel() var tm tenantMatcher err := tm.UnmarshalJSON(tc.input) + // Verify error behavior matches expectation. if (err != nil) != tc.expectError { t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tc.expectError) } + // Verify resulting enum value matches expected parsed type. if tm != tc.expected { t.Errorf("UnmarshalJSON() got = %v, want %v", tm, tc.expected) } From 3d7a99facb6d36de02ce06480abc1ac4397f2778 Mon Sep 17 00:00:00 2001 From: Bisshwajit Samanta Date: Thu, 13 Aug 2026 20:03:58 +0530 Subject: [PATCH 6/6] receive: correct doc comment regarding strict empty string validation Signed-off-by: Bisshwajit Samanta --- pkg/receive/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/receive/config.go b/pkg/receive/config.go index b46cde85a4e..98afa60be38 100644 --- a/pkg/receive/config.go +++ b/pkg/receive/config.go @@ -155,7 +155,7 @@ const ( // UnmarshalJSON implements the json.Unmarshaler interface for tenantMatcher. // We override the default unmarshaling to provide parse-time validation, -// ensuring that only valid matchers ("exact", "glob", or "") are accepted. +// ensuring that only valid matchers ("exact" or "glob") are accepted. // This allows Thanos to fail fast during configuration loading if a user // provides an invalid matcher type, rather than silently falling back at runtime. func (t *tenantMatcher) UnmarshalJSON(data []byte) error {