fluent-plugin-quota-throttle 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,61 @@
1
+ ##
2
+ # This module is responsible for matching records to quotas.
3
+ module Matcher
4
+
5
+ ##
6
+ # MatchHelper class is responsible for matching records to quotas.
7
+ # Methods:
8
+ # +get_quota+: Takes a list of keys and returns the quota that maximally matches
9
+ # +matching_score+: Calculates the matching score between two hashes.
10
+ class MatchHelper
11
+
12
+ def initialize(processed_quotas,default_quota)
13
+ @quotas = processed_quotas
14
+ @default_quota = default_quota
15
+ end
16
+
17
+ # Takes a list of keys and returns the quota that maximally matches
18
+ # If no quota matches, returns the default quota
19
+ # Params:
20
+ # +record+: (Hash) A hash of keys and values to match against the quotas
21
+ # Returns:
22
+ # +quota+: (Quota Class)The quota that maximally matches the record
23
+ def get_quota(record)
24
+
25
+ max_score = 0
26
+ quota_to_return = @default_quota
27
+ if @quotas.nil?
28
+ return @default_quota
29
+ end
30
+ @quotas.each do |quota|
31
+ score = matching_score(quota.match_by, record)
32
+ if score > max_score
33
+ max_score = score
34
+ quota_to_return = quota
35
+ end
36
+ end
37
+ quota_to_return
38
+ end
39
+
40
+ private
41
+
42
+ # Calculates the matching score between two hashes.
43
+ # Params:
44
+ # +match+: (Hash) A hash of keys and values to match against the record
45
+ # +record+: (Hash) A hash of keys and values to match against the match
46
+ def matching_score(match, record)
47
+ score = 0
48
+ if match.nil? || record.nil?
49
+ return 0
50
+ end
51
+ match.each do |key, value|
52
+ if record.dig(*key) == value
53
+ score += 1
54
+ else
55
+ return 0
56
+ end
57
+ end
58
+ score
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,113 @@
1
+ ##
2
+ # Rate Limiter module, contains the rate limiting logic
3
+ module RateLimiter
4
+
5
+ ##
6
+ # Bucket class, contains the rate limiting logic for each group
7
+ # Attributes:
8
+ # +bucket_count+: Number of requests in the bucket
9
+ # +bucket_last_reset+: Time when the bucket was last reset
10
+ # +approx_rate_per_second+: Approximate rate of requests per second
11
+ # +rate_last_reset+: Time when the rate was last reset
12
+ # +curr_count+: Number of requests in the current second
13
+ # +last_warning+: Time when the last warning was issued
14
+ # +timeout_s+: Timeout for the bucket
15
+ # +bucket_limit+: Maximum number of requests allowed in the bucket
16
+ # +bucket_period+: Time period for the bucket
17
+ # +rate_limit+: Maximum number of requests allowed per second
18
+ class Bucket
19
+ attr_accessor :bucket_count, :bucket_last_reset, :approx_rate_per_second, :rate_last_reset, :curr_count, :last_warning
20
+ attr_reader :bucket_limit, :bucket_period, :rate_limit, :timeout_s, :group
21
+ def initialize( group, bucket_limit, bucket_period)
22
+ now = Time.now
23
+ @group = group
24
+ @bucket_count = 0
25
+ @bucket_last_reset = now
26
+ @approx_rate_per_second = 0
27
+ @rate_last_reset = now
28
+ @curr_count = 0
29
+ @last_warning = nil
30
+ @bucket_limit = bucket_limit
31
+ @bucket_period = bucket_period
32
+ @rate_limit = bucket_limit/bucket_period
33
+ @timeout_s = 2*bucket_period
34
+ end
35
+
36
+ # Checks if the bucket is free or full
37
+ # Returns:
38
+ # +true+ if the bucket is free
39
+ # +false+ if the bucket is full
40
+ def allow
41
+ if @bucket_limit == -1
42
+ return true
43
+ end
44
+ now = Time.now
45
+ @curr_count += 1
46
+ time_lapsed = now - @rate_last_reset
47
+
48
+ if time_lapsed.to_i >= 1
49
+ @approx_rate_per_second = @curr_count / time_lapsed
50
+ @rate_last_reset = now
51
+ @curr_count = 0
52
+ end
53
+
54
+ if now.to_i / @bucket_period > @bucket_last_reset.to_i / @bucket_period
55
+ reset_bucket
56
+ end
57
+
58
+ if @bucket_count == -1 or @bucket_count > @bucket_limit
59
+ @bucket_count = -1
60
+ return false
61
+ else
62
+ @bucket_count += 1
63
+ true
64
+ end
65
+ end
66
+
67
+ # Checks if bucket is expired
68
+ # Returns:
69
+ # +true+ if the bucket is expired
70
+ # +false+ if the bucket is not expired
71
+ def expired
72
+ now = Time.now
73
+ now.to_i - @rate_last_reset.to_i > @timeout_s
74
+ end
75
+
76
+ private
77
+
78
+ # Resets the bucket when the window moves to the next time period
79
+ def reset_bucket
80
+ now = Time.now
81
+ unless @bucket_count == -1 && @approx_rate_per_second > @rate_limit
82
+ @bucket_count = 0
83
+ @bucket_last_reset = now
84
+ end
85
+ end
86
+ end
87
+
88
+ ##
89
+ # BucketStore class, organizes the all the group buckets
90
+ # Attributes:
91
+ # +buckets+: Hash containing all the group buckets
92
+ class BucketStore
93
+ def initialize
94
+ @buckets = {}
95
+ end
96
+
97
+ # Gets the bucket for the group
98
+ # Arguments:
99
+ # +group+: Group for which the bucket is required
100
+ # +quota+: Quota object containing the bucket size and duration
101
+ def get_bucket(group, quota)
102
+ @buckets[group] = @buckets.delete(group) || Bucket.new( group, quota.bucket_size, quota.duration)
103
+ end
104
+
105
+ # Cleans the buckets that have expired
106
+ def clean_buckets
107
+ lru_group, lru_bucket = @buckets.first
108
+ if !lru_group.nil? && lru_bucket.expired
109
+ @buckets.delete(lru_group)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,28 @@
1
+ quotas:
2
+ - name: quota1
3
+ description: first quota
4
+ group_by:
5
+ - group1.a
6
+ match_by:
7
+ group1.a: value1
8
+ bucket_size: 5
9
+ duration: 60s
10
+ action: drop
11
+ - name: quota2
12
+ description: second quota
13
+ group_by:
14
+ - group1.a
15
+ - group1.b
16
+ match_by:
17
+ group1.a: value2
18
+ group1.b: value3
19
+ bucket_size: 6
20
+ duration: 120s
21
+ action: reemit
22
+ default:
23
+ description: default quota
24
+ group_by:
25
+ - group1.a
26
+ bucket_size: 4
27
+ duration: 180s
28
+ action: reemit
@@ -0,0 +1,39 @@
1
+ quotas:
2
+ - name: quota1
3
+ description: first quota
4
+ group_by:
5
+ - group1.a
6
+ match_by:
7
+ group1.a: value1
8
+ bucket_size: 100
9
+ duration: 60s
10
+ action: drop
11
+ - name: quota2
12
+ description: second quota
13
+ group_by:
14
+ - group1.a
15
+ - group1.b
16
+ match_by:
17
+ group1.a: value2
18
+ group1.b: value3
19
+ bucket_size: 200
20
+ duration: 120s
21
+ action: reemit
22
+ - name: quota3
23
+ description: third quota
24
+ group_by:
25
+ - group2
26
+ - group3
27
+ match_by:
28
+ group2: value2
29
+ group3: value3
30
+ bucket_size: 300
31
+ duration: 180s
32
+ action: drop
33
+ default:
34
+ description: default quota
35
+ group_by:
36
+ - group1.a
37
+ bucket_size: 300
38
+ duration: 180s
39
+ action: reemit
@@ -0,0 +1,28 @@
1
+ quotas:
2
+ - name: quota1
3
+ description: first quota
4
+ group_by:
5
+ - group1.a
6
+ match_by:
7
+ group1.a: value1
8
+ bucket_size: 100
9
+ duration: 1m
10
+ action: drop
11
+ - name: quota2
12
+ description: second quota
13
+ group_by:
14
+ - group1.a
15
+ - group1.b
16
+ match_by:
17
+ group1.a: value2
18
+ group1.b: value3
19
+ bucket_size: 200
20
+ duration: 120s
21
+ action: reemit
22
+ default:
23
+ description: default quota
24
+ group_by:
25
+ - group1.a
26
+ bucket_size: 300
27
+ duration: 3m
28
+ action: reemit
@@ -0,0 +1,85 @@
1
+ require_relative '../../helper'
2
+
3
+ class QuotaThrottleFilterTest < Minitest::Test
4
+ include Fluent::Test::Helpers
5
+
6
+ def setup
7
+ Fluent::Test.setup
8
+ end
9
+
10
+ CONFIG = %[
11
+ path test/config_files/filter_plugin_test.yml
12
+ warning_delay 2m
13
+ enable_metrics false
14
+ ]
15
+
16
+ def create_driver(conf = CONFIG)
17
+ Fluent::Test::Driver::Filter.new(Fluent::Plugin::QuotaThrottleFilter).configure(conf)
18
+ end
19
+
20
+ def test_configure
21
+ d = create_driver
22
+ assert_equal "test/config_files/filter_plugin_test.yml", d.instance.path
23
+ assert_equal 120, d.instance.warning_delay
24
+ end
25
+
26
+ def test_filter
27
+ d = create_driver
28
+ d.run(default_tag: 'test') do
29
+ 10.times do
30
+ d.feed("group1" => { "a" => "value1" , "b" => "value2" })
31
+ d.feed("group1" => { "a" => "value2" , "b" => "value3" })
32
+ d.feed("group1" => { "a" => "value2" , "b" => "value2" })
33
+ d.feed("group1" => { "a" => "value3" , "b" => "value2" })
34
+ end
35
+ end
36
+ events = d.filtered_records
37
+ assert_equal 23, events.length
38
+ end
39
+
40
+ # Due to the way the Driver is implemented, both metrics tests cannot be same time because the registry of metrics is not cleared between tests
41
+ # def test_metrics_without_labels
42
+ # modified_config = CONFIG.sub("enable_metrics false", "enable_metrics true")
43
+ # d = create_driver(modified_config)
44
+ # d.run(default_tag: 'test') do
45
+ # 10.times do
46
+ # d.feed("group1" => { "a" => "value1" , "b" => "value2" })
47
+ # d.feed("group1" => { "a" => "value2" , "b" => "value3" })
48
+ # d.feed("group1" => { "a" => "value2" , "b" => "value2" })
49
+ # d.feed("group1" => { "a" => "value3" , "b" => "value2" })
50
+ # end
51
+ # end
52
+ # assert_equal 10, d.instance.registry.get(:fluentd_quota_throttle_input).get(labels: {quota: 'quota1'})
53
+ # assert_equal 4, d.instance.registry.get(:fluentd_quota_throttle_exceeded).get(labels: {quota: 'quota1'})
54
+ # assert_equal 10, d.instance.registry.get(:fluentd_quota_throttle_input).get(labels: {quota: 'quota2'})
55
+ # assert_equal 3, d.instance.registry.get(:fluentd_quota_throttle_exceeded).get(labels: {quota: 'quota2'})
56
+ # assert_equal 20, d.instance.registry.get(:fluentd_quota_throttle_input).get(labels: {quota: 'default'})
57
+ # assert_equal 10, d.instance.registry.get(:fluentd_quota_throttle_exceeded).get(labels: {quota: 'default'})
58
+ # end
59
+ def test_metrics_with_labels
60
+ labels = %[
61
+ <labels>
62
+ source $.group1.a
63
+ dummy d1
64
+ </labels>
65
+ ]
66
+ modified_config = CONFIG.sub("enable_metrics false", "enable_metrics true" + labels)
67
+ d = create_driver(modified_config)
68
+ d.run(default_tag: 'test') do
69
+ 10.times do
70
+ d.feed("group1" => { "a" => "value1" , "b" => "value2" })
71
+ d.feed("group1" => { "a" => "value2" , "b" => "value3" })
72
+ d.feed("group1" => { "a" => "value2" , "b" => "value2" })
73
+ d.feed("group1" => { "a" => "value3" , "b" => "value2" })
74
+ end
75
+ end
76
+ assert_equal 10, d.instance.registry.get(:fluentd_quota_throttle_input).get(labels: {source: "value1", quota: 'quota1', dummy: 'd1'})
77
+ assert_equal 4, d.instance.registry.get(:fluentd_quota_throttle_exceeded).get(labels: {source: "value1", quota: 'quota1', dummy: 'd1'})
78
+ assert_equal 10, d.instance.registry.get(:fluentd_quota_throttle_input).get(labels: {source: "value2", quota: 'quota2', dummy: 'd1'})
79
+ assert_equal 3, d.instance.registry.get(:fluentd_quota_throttle_exceeded).get(labels: {source: "value2", quota: 'quota2', dummy: 'd1'})
80
+ assert_equal 10, d.instance.registry.get(:fluentd_quota_throttle_input).get(labels: {source: "value2", quota: 'default', dummy: 'd1'})
81
+ assert_equal 5, d.instance.registry.get(:fluentd_quota_throttle_exceeded).get(labels: {source: "value2", quota: 'default', dummy: 'd1'})
82
+ assert_equal 10, d.instance.registry.get(:fluentd_quota_throttle_input).get(labels: {source: "value3", quota: 'default', dummy: 'd1'})
83
+ assert_equal 5, d.instance.registry.get(:fluentd_quota_throttle_exceeded).get(labels: {source: "value3", quota: 'default', dummy: 'd1'})
84
+ end
85
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,10 @@
1
+ $LOAD_PATH.unshift File.expand_path("../test", __dir__)
2
+ require 'fluent/test'
3
+ require 'fluent/test/driver/filter'
4
+ require 'fluent/test/helpers'
5
+ require 'yaml'
6
+ require 'minitest/autorun'
7
+ require_relative '../lib/fluent/plugin/config_parser'
8
+ require_relative '../lib/fluent/plugin/matcher'
9
+ require_relative '../lib/fluent/plugin/rate_limiter'
10
+ require_relative '../lib/fluent/plugin/filter_quota_throttle'
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../helper'
3
+
4
+ class ParserTest < Minitest::Test
5
+ def setup
6
+ config_file_path = Dir.pwd+"/test/config_files/parser_test.yml"
7
+ config_parser = ConfigParser::Configuration.new(config_file_path)
8
+ @quotas = config_parser.quotas
9
+ @default_quota = config_parser.default_quota
10
+ end
11
+
12
+ def test_parse_quotas
13
+ # Check if the quotas are parsed correctly
14
+ # TODO 1: Add wrong configuration files and check if it raises errors
15
+ refute_nil @quotas
16
+ assert_equal 2, @quotas.length
17
+
18
+ # Check if the quotas are parsed correctly
19
+ assert_equal "quota1", @quotas[0].name
20
+ assert_equal "quota2", @quotas[1].name
21
+ assert_equal "first quota", @quotas[0].desc
22
+ assert_equal "second quota", @quotas[1].desc
23
+ assert_equal [["group1", "a"]], @quotas[0].group_by
24
+ assert_equal [["group1", "a"], ["group1", "b"]], @quotas[1].group_by
25
+ assert_equal 100, @quotas[0].bucket_size
26
+ assert_equal 200, @quotas[1].bucket_size
27
+ assert_equal 60, @quotas[0].duration
28
+ assert_equal 120, @quotas[1].duration
29
+ assert_equal "drop", @quotas[0].action
30
+ assert_equal "reemit", @quotas[1].action
31
+ assert_equal ({["group1", "a"] => "value1"}), @quotas[0].match_by
32
+ assert_equal ({["group1", "a"] => "value2", ["group1", "b"] => "value3"}), @quotas[1].match_by
33
+ assert_equal "default", @default_quota.name
34
+ assert_equal "default quota", @default_quota.desc
35
+ assert_equal [["group1", "a"]], @default_quota.group_by
36
+ assert_equal [], @default_quota.match_by
37
+ assert_equal 300, @default_quota.bucket_size
38
+ assert_equal 180, @default_quota.duration
39
+ assert_equal "reemit", @default_quota.action
40
+ end
41
+
42
+ end
@@ -0,0 +1,67 @@
1
+ require_relative '../helper'
2
+
3
+ class MatcherTest < Minitest::Test
4
+ def setup
5
+ config_file_path = Dir.pwd+"/test/config_files/matcher_test.yml"
6
+ config_parser = ConfigParser::Configuration.new(config_file_path)
7
+ quotas = config_parser.quotas
8
+ @default_quota = config_parser.default_quota
9
+ @match_helper = Matcher::MatchHelper.new(quotas,@default_quota)
10
+ end
11
+
12
+ def test_get_quota
13
+ # Check if the correct quota is retrieved from above definition
14
+ # UT 1: Subset of groups match fully
15
+ keys = { "group1" => { "a" => "value1" , "b" => "value2" } }
16
+ quota = @match_helper.get_quota(keys)
17
+ assert_equal "quota1", quota.name
18
+
19
+ # UT 2: All groups match fully
20
+ keys = { "group1" => {"a" => "value2" , "b" => "value3" } }
21
+ quota = @match_helper.get_quota(keys)
22
+ assert_equal "quota2", quota.name
23
+
24
+ # UT 3: Subset of group match partially
25
+ keys = { "group1" => { "a" => "value2" , "b" => "value2" } }
26
+ quota = @match_helper.get_quota(keys)
27
+ assert_equal @default_quota, quota
28
+
29
+ # UT 4: None of the group matches
30
+ keys = { "group1" => { "a" => "value3" , "b" => "value2" } }
31
+ quota = @match_helper.get_quota(keys)
32
+ assert_equal @default_quota, quota
33
+
34
+ # UT 5: Non-nested group matches
35
+ keys = { "group2" => "value2" , "group3" => "value3" }
36
+ quota = @match_helper.get_quota(keys)
37
+ assert_equal "quota3", quota.name
38
+
39
+ # UT 6: Non-nested group mismatches
40
+ keys = { "group2" => "value2" , "group3" => "value4" }
41
+ quota = @match_helper.get_quota(keys)
42
+ assert_equal @default_quota, quota
43
+ end
44
+
45
+ def test_matching_score
46
+ # Testing the private helper function
47
+ score = @match_helper.send(:matching_score, { "key1" => "value1" , "key2" => "value2" }, { "key1" => "value1" , "key2" => "value2" })
48
+ assert_equal 2, score
49
+
50
+ score = @match_helper.send(:matching_score, { "key1" => "value1" }, { "key2" => "value2" })
51
+ assert_equal 0, score
52
+
53
+ score = @match_helper.send(:matching_score, { "key1" => "value1" , "key2" => "value2" }, { "key1" => "value1" , "key2" => "value3" })
54
+ assert_equal 0, score
55
+
56
+ score = @match_helper.send(:matching_score, nil, { "key1" => "value1" })
57
+ assert_equal 0, score
58
+
59
+ score = @match_helper.send(:matching_score, { "key1" => "value1" }, nil)
60
+ assert_equal 0, score
61
+
62
+ score = @match_helper.send(:matching_score, nil, nil)
63
+ assert_equal 0, score
64
+ end
65
+
66
+
67
+ end
@@ -0,0 +1,72 @@
1
+ require_relative '../helper'
2
+
3
+ class TestBucket < Minitest::Test
4
+ def setup
5
+ # Initialize bucket with dummy parameters
6
+ @bucket = RateLimiter::Bucket.new( ["group"] ,10, 2)
7
+ end
8
+
9
+ def test_bucket_initialization
10
+ assert_equal 0, @bucket.bucket_count
11
+ assert @bucket.bucket_last_reset <= Time.now
12
+ assert_equal 0, @bucket.approx_rate_per_second
13
+ assert @bucket.rate_last_reset <= Time.now
14
+ assert_equal 0, @bucket.curr_count
15
+ assert_nil @bucket.last_warning
16
+ assert_equal 10, @bucket.instance_variable_get(:@bucket_limit)
17
+ assert_equal 2, @bucket.instance_variable_get(:@bucket_period)
18
+ assert_equal 5, @bucket.instance_variable_get(:@rate_limit)
19
+ assert_equal 4, @bucket.timeout_s
20
+ end
21
+
22
+ def test_bucket_allow_free
23
+ @bucket.allow
24
+ assert_equal 1, @bucket.bucket_count
25
+ end
26
+
27
+ def test_bucket_allow_full
28
+ 11.times { @bucket.allow }
29
+ assert_equal false, @bucket.allow
30
+ end
31
+
32
+ def test_reset_bucket
33
+ @bucket.allow
34
+ @bucket.send(:reset_bucket)
35
+ assert_equal 0, @bucket.bucket_count
36
+ end
37
+
38
+ def test_expired
39
+ @bucket.allow
40
+ assert_equal false, @bucket.expired
41
+ sleep(5)
42
+ assert_equal true, @bucket.expired
43
+ end
44
+ end
45
+
46
+ class TestBucketStore < Minitest::Test
47
+ def setup
48
+ @bucket_store = RateLimiter::BucketStore.new
49
+ @quota = ConfigParser::Quota.new("Dummy", "dummy quota for testing",[["group1","a"],["group1","b"]], {["group1","a"] => "value1"}, 10, "2s", "reemit")
50
+ end
51
+
52
+ def test_get_bucket
53
+ group = "value1"
54
+ bucket = @bucket_store.get_bucket(group, @quota)
55
+ assert_instance_of RateLimiter::Bucket, bucket
56
+ assert_equal 10, bucket.instance_variable_get(:@bucket_limit)
57
+ assert_equal 2, bucket.instance_variable_get(:@bucket_period)
58
+ end
59
+
60
+ def test_clean_buckets
61
+ group1 = "value1"
62
+ @bucket_store.get_bucket(group1, @quota)
63
+ group2 = "value2"
64
+ @bucket_store.get_bucket(group2, @quota)
65
+ lru_group, lru_counter = @bucket_store.instance_variable_get(:@buckets).first
66
+ assert_equal group1, lru_group
67
+ sleep(5)
68
+ @bucket_store.clean_buckets
69
+ lru_group, lru_counter = @bucket_store.instance_variable_get(:@buckets).first
70
+ assert_equal group2, lru_group
71
+ end
72
+ end
@@ -0,0 +1,24 @@
1
+ <source>
2
+ @type forward
3
+ port 24224
4
+ </source>
5
+
6
+ <source>
7
+ @type prometheus
8
+ port 24231
9
+ </source>
10
+
11
+ <filter test.**>
12
+ @log_level debug
13
+ @type quota_throttle
14
+ path example.yml
15
+ warning_delay 30
16
+ enable_metrics true
17
+ <labels>
18
+ user_id $.user_id
19
+ </labels>
20
+ </filter>
21
+
22
+ <match **>
23
+ @type stdout
24
+ </match>
@@ -0,0 +1,24 @@
1
+ default:
2
+ action: drop
3
+ bucket_size: 6
4
+ duration: 5s
5
+ group_by:
6
+ - "user_id"
7
+
8
+ quotas:
9
+ - name: "Quota1"
10
+ action: reemit
11
+ bucket_size: 8
12
+ duration: 5s
13
+ group_by:
14
+ - "user_id"
15
+ match_by:
16
+ user_id: "user1"
17
+ - name: "Quota2"
18
+ action: drop
19
+ bucket_size: 5
20
+ duration: 5s
21
+ group_by:
22
+ - "user_id"
23
+ match_by:
24
+ user_id: "user2"
@@ -0,0 +1,10 @@
1
+ for i in {1..20}; do
2
+ echo "{\"group\": \"group1\", \"user_id\": \"user1\", \"message\": \"Test log $i\"}" | fluent-cat test.default
3
+ done
4
+ for i in {1..20}; do
5
+ echo "{\"group\": \"group1\", \"user_id\": \"user3\", \"message\": \"Test log $i\"}" | fluent-cat test.default
6
+ done
7
+ for i in {1..20}; do
8
+ echo "{\"group\": \"group1\", \"user_id\": \"user2\", \"message\": \"Test log $i\"}" | fluent-cat test.default
9
+ done
10
+