admin: accept a list of collections in the task collection filter (#10953)

The collection filter was parsed twice with two syntaxes: the master-side
volume listing compiled the whole string as one regex, while EC encode and
EC balance detection split it on commas and matched each entry as a
wildcard. A volume had to pass both, so "collection-a,collection-b" matched
nothing (no collection is named that), and the ALL_COLLECTIONS sentinel,
which the master side skips, dropped every volume at the task side.

Parse it once, in one place: a comma-separated list where an entry is a
name with optional * and ? wildcards, or a regex when it carries regex
syntax. A regex entry now has to match the whole name unless it anchors
itself, so listing a collection no longer picks up its longer namesakes.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-08-25 15:13:06 -07:00
1 parent ef4c9d9178
commit e482e67971
17 files changed
+235 -48

No files matched your search

+77 -2
View File
@@ -1,14 +1,89 @@
package pluginworker
import (
"fmt"
"regexp"
"strings"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// CollectionFilterMode controls how collections are interpreted during
// detection. The two recognized sentinels short-circuit regex matching:
// detection. The two recognized sentinels short-circuit matching:
// - CollectionFilterAll: pool every collection together (default).
// - CollectionFilterEach: run detection separately per collection.
//
// Any other non-empty value is treated as a regex.
// Any other non-empty value is a comma-separated list of collection patterns.
type CollectionFilterMode string
const (
CollectionFilterAll CollectionFilterMode = "ALL_COLLECTIONS"
CollectionFilterEach CollectionFilterMode = "EACH_COLLECTION"
)
// regexMetaCharacters mark a filter entry as a regex; "*" and "?" stay wildcards.
const regexMetaCharacters = `.^$+()[]{}|\`
// CollectionMatcher matches a volume collection against a collection_filter value.
type CollectionMatcher struct {
wildcards []string
regexes []*regexp.Regexp
}
// CompileCollectionMatcher parses a collection_filter into a matcher. A nil
// matcher accepts every collection: that is the empty filter, "*", and both
// mode sentinels. Any other value is a comma-separated list, and a collection
// passes when one entry matches it. An entry is a name, optionally with "*" and
// "?" wildcards, or a regex when it carries regex syntax. A regex entry must
// match the whole name unless it anchors itself with "^" or "$".
func CompileCollectionMatcher(filter string) (*CollectionMatcher, error) {
trimmed := strings.TrimSpace(filter)
mode := CollectionFilterMode(trimmed)
if trimmed == "" || trimmed == "*" || mode == CollectionFilterAll || mode == CollectionFilterEach {
return nil, nil
}
matcher := &CollectionMatcher{}
for _, entry := range strings.Split(trimmed, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if !strings.ContainsAny(entry, regexMetaCharacters) {
matcher.wildcards = append(matcher.wildcards, entry)
continue
}
pattern := entry
if !strings.ContainsAny(entry, "^$") {
pattern = "^(?:" + entry + ")$"
}
compiled, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid collection_filter entry %q: %w", entry, err)
}
matcher.regexes = append(matcher.regexes, compiled)
}
if len(matcher.wildcards) == 0 && len(matcher.regexes) == 0 {
return nil, nil
}
return matcher, nil
}
// Matches reports whether a collection passes the filter. A nil matcher accepts everything.
func (m *CollectionMatcher) Matches(collection string) bool {
if m == nil {
return true
}
for _, pattern := range m.wildcards {
if wildcard.MatchesWildcard(pattern, collection) {
return true
}
}
for _, re := range m.regexes {
if re.MatchString(collection) {
return true
}
}
return false
}
@@ -0,0 +1,48 @@
package pluginworker
import "testing"
func TestCompileCollectionMatcher(t *testing.T) {
cases := []struct {
filter string
matches []string
misses []string
}{
{filter: "", matches: []string{"", "photos"}},
{filter: "*", matches: []string{"", "photos"}},
{filter: string(CollectionFilterAll), matches: []string{"", "photos"}},
{filter: string(CollectionFilterEach), matches: []string{"", "photos"}},
{filter: "photos", matches: []string{"photos"}, misses: []string{"", "photos-backup", "myphotos"}},
{filter: "collection-a,collection-b", matches: []string{"collection-a", "collection-b"}, misses: []string{"collection-c"}},
{filter: " collection-a , collection-b ,", matches: []string{"collection-a", "collection-b"}, misses: []string{"collection-c"}},
{filter: "photos*,videos", matches: []string{"photos", "photos-backup", "videos"}, misses: []string{"clips"}},
{filter: "photo?", matches: []string{"photos"}, misses: []string{"photo", "photos-backup"}},
// A regex entry matches the whole name unless it anchors itself.
{filter: "photos|videos", matches: []string{"photos", "videos"}, misses: []string{"photos-backup"}},
{filter: "^photos", matches: []string{"photos", "photos-backup"}, misses: []string{"videos"}},
{filter: "photos-.*,videos", matches: []string{"photos-backup", "videos"}, misses: []string{"photos"}},
}
for _, c := range cases {
matcher, err := CompileCollectionMatcher(c.filter)
if err != nil {
t.Fatalf("CompileCollectionMatcher(%q): %v", c.filter, err)
}
for _, collection := range c.matches {
if !matcher.Matches(collection) {
t.Errorf("filter %q should match collection %q", c.filter, collection)
}
}
for _, collection := range c.misses {
if matcher.Matches(collection) {
t.Errorf("filter %q should not match collection %q", c.filter, collection)
}
}
}
}
func TestCompileCollectionMatcherInvalidEntry(t *testing.T) {
if _, err := CompileCollectionMatcher("photos,[invalid"); err == nil {
t.Fatal("expected an error for an unparsable entry")
}
}
+4 -11
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
@@ -137,15 +136,9 @@ func buildVolumeMetrics(
return nil, nil, nil, err
}
var collectionRegex *regexp.Regexp
trimmedFilter := strings.TrimSpace(collectionFilter)
filterMode := CollectionFilterMode(trimmedFilter)
if trimmedFilter != "" && filterMode != CollectionFilterAll && filterMode != CollectionFilterEach && trimmedFilter != "*" {
var err error
collectionRegex, err = regexp.Compile(trimmedFilter)
if err != nil {
return nil, nil, nil, &configError{err: fmt.Errorf("invalid collection_filter regex %q: %w", trimmedFilter, err)}
}
collectionMatcher, err := CompileCollectionMatcher(collectionFilter)
if err != nil {
return nil, nil, nil, &configError{err: err}
}
volumeSizeLimitBytes := uint64(response.VolumeSizeLimitMb) * 1024 * 1024
@@ -167,7 +160,7 @@ func buildVolumeMetrics(
Host: pb.NewServerAddressFromDataNode(node).ToHost(),
})
if collectionRegex != nil && !collectionRegex.MatchString(volume.Collection) {
if !collectionMatcher.Matches(volume.Collection) {
continue
}
+20
View File
@@ -95,6 +95,26 @@ func TestBuildVolumeMetricsRegexFilter(t *testing.T) {
}
}
func TestBuildVolumeMetricsCollectionList(t *testing.T) {
resp := makeTestVolumeListResponse(
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
&master_pb.VolumeInformationMessage{Id: 3, Collection: "clips", Size: 300},
)
metrics, _, _, err := buildVolumeMetrics(resp, "photos,videos")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("expected 2 metrics, got %d", len(metrics))
}
for _, metric := range metrics {
if metric.Collection != "photos" && metric.Collection != "videos" {
t.Fatalf("unexpected collection %q", metric.Collection)
}
}
}
func TestBuildVolumeMetricsInvalidRegex(t *testing.T) {
resp := makeTestVolumeListResponse(
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},