fluent-plugin-quota-throttle 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+