From d1587b76ce735d75775301da9bf68e8e7c37afcf Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Tue, 16 Dec 2025 20:43:09 +0100 Subject: [PATCH 1/3] Add Aho Corasick index for pass/skip filters --- .golangci.yml | 1 + cmd/catp/catp/app.go | 8 ++- cmd/catp/catp/catp.go | 45 ++---------- cmd/catp/catp/filter.go | 131 +++++++++++++++++++++++++++++++++++ cmd/catp/catp/filter_test.go | 26 +++++++ go.mod | 1 + go.sum | 2 + 7 files changed, 172 insertions(+), 42 deletions(-) create mode 100644 cmd/catp/catp/filter.go create mode 100644 cmd/catp/catp/filter_test.go diff --git a/.golangci.yml b/.golangci.yml index f7fc99f..7319b4d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -22,6 +22,7 @@ linters-settings: linters: enable-all: true disable: + - testpackage - gocognit - gocyclo - funlen diff --git a/cmd/catp/catp/app.go b/cmd/catp/catp/app.go index 5ddcae7..565f2c4 100644 --- a/cmd/catp/catp/app.go +++ b/cmd/catp/catp/app.go @@ -29,7 +29,7 @@ func Main(options ...func(o *Options)) error { //nolint:funlen,cyclop,gocognit,g r := &runner{} flag.Var(flagFunc(func(v string) error { - r.filters = append(r.filters, filter{pass: true, and: bytes.Split([]byte(v), []byte("^"))}) + r.filters.addFilter(true, bytes.Split([]byte(v), []byte("^"))...) return nil }), "pass", "filter matching, may contain multiple AND patterns separated by ^,\n"+ @@ -44,7 +44,7 @@ func Main(options ...func(o *Options)) error { //nolint:funlen,cyclop,gocognit,g flag.BoolFunc("pass-any", "finishes matching and gets the value even if previous -pass did not match,\n"+ "if previous -skip matched, the line would be skipped any way.", func(s string) error { - r.filters = append(r.filters, filter{pass: true}) + r.filters.addPassAny() return nil }) @@ -55,7 +55,7 @@ func Main(options ...func(o *Options)) error { //nolint:funlen,cyclop,gocognit,g "each line is treated as -skip, each column value is AND condition.") flag.Var(flagFunc(func(v string) error { - r.filters = append(r.filters, filter{pass: false, and: bytes.Split([]byte(v), []byte("^"))}) + r.filters.addFilter(false, bytes.Split([]byte(v), []byte("^"))...) return nil }), "skip", "filter matching, may contain multiple AND patterns separated by ^,\n"+ @@ -94,6 +94,8 @@ func Main(options ...func(o *Options)) error { //nolint:funlen,cyclop,gocognit,g } flag.Parse() + r.filters.buildIndex() + if *ver { fmt.Println(version.Module("github.com/bool64/progress").Version) diff --git a/cmd/catp/catp/catp.go b/cmd/catp/catp/catp.go index 5ac34cf..3d86882 100644 --- a/cmd/catp/catp/catp.go +++ b/cmd/catp/catp/catp.go @@ -2,7 +2,6 @@ package catp import ( "bufio" - "bytes" "context" "encoding/csv" "encoding/json" @@ -45,7 +44,7 @@ type runner struct { currentBytesUncompressed int64 currentLines int64 - filters []filter + filters filters currentFile *progress.CountingReader currentTotal int64 @@ -66,13 +65,7 @@ type runner struct { hasCompression bool } -type ( - filter struct { - pass bool // Skip is false. - and [][]byte - } - flagFunc func(v string) error -) +type flagFunc func(v string) error func (f flagFunc) String() string { return "" } func (f flagFunc) Set(value string) error { return f(value) } @@ -172,7 +165,7 @@ func (r *runner) st(s progress.Status) string { atomic.StoreInt64(&r.lastBytesUncompressed, currentBytesUncompressed) } - if len(r.filters) > 0 || r.options.PrepareLine != nil { + if r.filters.isSet() || r.options.PrepareLine != nil { m := atomic.LoadInt64(&r.matches) pr.Matches = &m res += fmt.Sprintf(", matches %d", m) @@ -251,7 +244,7 @@ func (r *runner) scanFile(filename string, rd io.Reader, out io.Writer) { line := s.Bytes() - if !r.shouldWrite(line) { + if !r.filters.shouldWrite(line) { continue } @@ -297,32 +290,6 @@ func (r *runner) scanFile(filename string, rd io.Reader, out io.Writer) { } } -func (r *runner) shouldWrite(line []byte) bool { - shouldWrite := true - - for _, f := range r.filters { - if f.pass { - shouldWrite = false - } - - andMatched := true - - for _, andFilter := range f.and { - if !bytes.Contains(line, andFilter) { - andMatched = false - - break - } - } - - if andMatched { - return f.pass - } - } - - return shouldWrite -} - func (r *runner) cat(filename string) (err error) { //nolint:gocyclo var rd io.Reader @@ -432,7 +399,7 @@ func (r *runner) cat(filename string) (err error) { //nolint:gocyclo r.limiter = rate.NewLimiter(rate.Limit(r.rateLimit), 100) } - if len(r.filters) > 0 || r.parallel > 1 || r.hasOptions || r.countLines || r.rateLimit > 0 { + if r.filters.isSet() || r.parallel > 1 || r.hasOptions || r.countLines || r.rateLimit > 0 { r.scanFile(filename, rd, out) } else { r.readFile(rd, out) @@ -524,7 +491,7 @@ func (r *runner) loadCSVFilter(fn string, pass bool) error { and = append(and, []byte(v)) } - r.filters = append(r.filters, filter{pass: pass, and: and}) + r.filters.addFilter(pass, and...) } return nil diff --git a/cmd/catp/catp/filter.go b/cmd/catp/catp/filter.go new file mode 100644 index 0000000..bfd00e5 --- /dev/null +++ b/cmd/catp/catp/filter.go @@ -0,0 +1,131 @@ +package catp + +import ( + "bytes" + + "github.com/cloudflare/ahocorasick" +) + +type ( + filterAnd [][]byte + filterGroup struct { + pass bool + ors []filterAnd + + // Prefilter checks for match of the first element of any ors item. + // This first element is removed from and. + pre *ahocorasick.Matcher + } + filters struct { + g []*filterGroup + } +) + +func (f *filters) buildIndex() { + for _, g := range f.g { + g.buildIndex() + } +} + +func (f *filters) isSet() bool { + return len(f.g) > 0 +} + +func (f *filters) addFilterString(pass bool, and ...string) { + andb := make([][]byte, 0, len(and)) + + for _, item := range and { + andb = append(andb, []byte(item)) + } + + f.addFilter(pass, andb...) +} + +func (f *filters) addPassAny() { + f.g = append(f.g, &filterGroup{pass: true}) +} + +func (f *filters) addFilter(pass bool, and ...[]byte) { + if len(and) == 0 { + return + } + + var g *filterGroup + + // Get current group if exists and has same pass, append new current group with new pass otherwise. + if len(f.g) != 0 { + g = f.g[len(f.g)-1] + + if g.pass != pass { + g = &filterGroup{pass: pass} + f.g = append(f.g, g) + } + } else { + // Create and append the very first group. + g = &filterGroup{pass: pass} + f.g = append(f.g, g) + } + + g.ors = append(g.ors, and) +} + +func (f *filters) shouldWrite(line []byte) bool { + shouldWrite := true + + for _, g := range f.g { + if g.pass { + shouldWrite = false + } + + matched := g.match(line) + + if matched { + return g.pass + } + } + + return shouldWrite +} + +func (g *filterGroup) match(line []byte) bool { + if g.pre != nil { + if !g.pre.Contains(line) { + return false + } + } + + for _, or := range g.ors { + andMatched := true + + for _, and := range or { + if !bytes.Contains(line, and) { + andMatched = false + + break + } + } + + if andMatched { + return true + } + } + + return false +} + +func (g *filterGroup) buildIndex() { + if g.pre != nil { + return + } + + if len(g.ors) < 5 { + return + } + + indexItems := make([][]byte, 0, len(g.ors)) + for _, or := range g.ors { + indexItems = append(indexItems, or[0]) + } + + g.pre = ahocorasick.NewMatcher(indexItems) +} diff --git a/cmd/catp/catp/filter_test.go b/cmd/catp/catp/filter_test.go new file mode 100644 index 0000000..fb2d5c0 --- /dev/null +++ b/cmd/catp/catp/filter_test.go @@ -0,0 +1,26 @@ +package catp + +import ( + "bytes" + "os" + "testing" +) + +func TestFilter_Match(t *testing.T) { + f := filters{} + + f.addFilterString(false, "dbg") + f.addFilterString(true, "linux", "64") + f.addFilterString(true, "windows") + + input, err := os.ReadFile("./testdata/release-assets.yml") + if err != nil { + t.Fatal(err) + } + + for _, line := range bytes.Split(input, []byte("\n")) { + if f.shouldWrite(line) { + println(string(line)) + } + } +} diff --git a/go.mod b/go.mod index 4e5ffaa..a7ffa9e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require ( github.com/DataDog/zstd v1.5.7 github.com/bool64/dev v0.2.40 + github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396 github.com/klauspost/compress v1.18.0 github.com/klauspost/pgzip v1.2.6 golang.org/x/time v0.12.0 diff --git a/go.sum b/go.sum index a28fd52..9132398 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/bool64/dev v0.2.40 h1:LUSD+Aq+WB3KwVntqXstevJ0wB12ig1bEgoG8ZafsZU= github.com/bool64/dev v0.2.40/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396 h1:W2HK1IdCnCGuLUeyizSCkwvBjdj0ZL7mxnJYQ3poyzI= +github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396/go.mod h1:tGWUZLZp9ajsxUOnHmFFLnqnlKXsCn6GReG4jAD59H0= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= From 2ab7fa513cf45743bc4b2f36f446c9ae6aa553e9 Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Tue, 16 Dec 2025 20:55:04 +0100 Subject: [PATCH 2/3] Optimize performance --- cmd/catp/catp/filter.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/cmd/catp/catp/filter.go b/cmd/catp/catp/filter.go index bfd00e5..3652522 100644 --- a/cmd/catp/catp/filter.go +++ b/cmd/catp/catp/filter.go @@ -89,9 +89,30 @@ func (f *filters) shouldWrite(line []byte) bool { func (g *filterGroup) match(line []byte) bool { if g.pre != nil { - if !g.pre.Contains(line) { + ids := g.pre.Match(line) + if len(ids) == 0 { return false } + + for _, id := range ids { + or := g.ors[id] + + andMatched := true + + for _, and := range or { + if !bytes.Contains(line, and) { + andMatched = false + + break + } + } + + if andMatched { + return true + } + } + + return false } for _, or := range g.ors { @@ -123,8 +144,9 @@ func (g *filterGroup) buildIndex() { } indexItems := make([][]byte, 0, len(g.ors)) - for _, or := range g.ors { + for i, or := range g.ors { indexItems = append(indexItems, or[0]) + g.ors[i] = or[1:] } g.pre = ahocorasick.NewMatcher(indexItems) From 2ec3adcdc7e455af7bec7b88109c53eb7c1c0c50 Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Wed, 17 Dec 2025 00:16:02 +0100 Subject: [PATCH 3/3] Update README, refresh default.pgo --- cmd/catp/README.md | 33 ++++++++++++++++++++++++++------- cmd/catp/default.pgo | Bin 6611 -> 2692 bytes 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cmd/catp/README.md b/cmd/catp/README.md index 789fd9a..ada3ab7 100644 --- a/cmd/catp/README.md +++ b/cmd/catp/README.md @@ -44,18 +44,26 @@ catp [OPTIONS] PATH ... use 0 for multi-threaded zst decoder (slightly faster at cost of more CPU) (default 1) -pass value filter matching, may contain multiple AND patterns separated by ^, - if filter matches, line is passed to the output (unless filtered out by -skip) - each -pass value is added with OR logic, - for example, you can use "-pass bar^baz -pass foo" to only keep lines that have (bar AND baz) OR foo + if filter matches, line is passed to the output (may be filtered out by preceding -skip) + other -pass values are evaluated if preceding pass/skip did not match, + for example, you can use "-pass bar^baz -pass foo -skip fo" to only keep lines that have (bar AND baz) OR foo, but not fox + -pass-any + finishes matching and gets the value even if previous -pass did not match, + if previous -skip matched, the line would be skipped any way. + -pass-csv value + filter matching, loads pass params from CSV file, + each line is treated as -pass, each column value is AND condition. -progress-json string write current progress to a file -rate-limit float output rate limit lines per second -skip value filter matching, may contain multiple AND patterns separated by ^, - if filter matches, line is removed from the output (even if it passed -pass) - each -skip value is added with OR logic, + if filter matches, line is removed from the output (may be kept if it passed preceding -pass) for example, you can use "-skip quux^baz -skip fooO" to skip lines that have (quux AND baz) OR fooO + -skip-csv value + filter matching, loads skip params from CSV file, + each line is treated as -skip, each column value is AND condition. -version print version and exit ``` @@ -77,10 +85,10 @@ get-key.log: 100.0% bytes read, 1000000 lines processed, 8065.7 l/s, 41.8 MB/s, ``` Run log filtering (lines containing `foo bar` or `baz`) on multiple files in background (with `screen`) and output to a -new file. +new compressed file. ``` -screen -dmS foo12 ./catp -output ~/foo-2023-07-12.log -pass "foo bar" -pass "baz" /home/logs/server-2023-07-12* +screen -dmS foo12 ./catp -output ~/foo-2023-07-12.log.zst -pass "foo bar" -pass "baz" /home/logs/server-2023-07-12* ``` ``` @@ -100,3 +108,14 @@ all: 32.3% bytes read, /home/logs/server-2023-07-12-09-00.log_6.zst: 5.1% bytes # detaching from screen with ctrl+a+d ``` +Filter based on large list of needles. Values from allow and block lists are loaded into high-performance +[Aho Corasick](https://en.wikipedia.org/wiki/Aho%E2%80%93Corasick_algorithm) indexes. + +``` +catp -pass-csv allowlist.csv -skip-csv blocklist.csv -pass-any -output filtered.log.zst source.log.zst +``` + +Each source line would follow the filtering pipeline: +* if `allowlist.csv` has at least one row, all cells of which are present in the source line, source line gets into output +* if not, but if `blocklist.csv` has at least one row, all cells of which are present in the source line, source line is skipped +* if not, source line gets into output because of `-pass-any` diff --git a/cmd/catp/default.pgo b/cmd/catp/default.pgo index 9f053fe5bfd15d2214f4edfedbb2e462cac38056..7e9358b55ac42a9993a8266bad3f299abea783fa 100644 GIT binary patch literal 2692 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 literal 6611 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 R{{R30|Nkv195HM|000JmqoDu*