gitlab-labkit 2.0.0 → 2.1.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c6ab89c2e34ad08721cee1e920601a8d6d039bfe8a6552777021714f17d0c74
4
- data.tar.gz: 6b9e36ac9ce69866890d6df28132d8abd280cf7d3b4ab7b748470864dbf8a70e
3
+ metadata.gz: 1457da9fa7cec587b47834870a4eb60f5a21c9966111d1d9edd0e7e0a49b4ff3
4
+ data.tar.gz: 631e56481d2be50e976a16aefa6fd6a250759089b56dde0414af059fd55c4ba7
5
5
  SHA512:
6
- metadata.gz: af23293cd5d9afe2dd3ba93ca4cd98a6ae5178c04079069e60defff46652266158fc7d74fe335d9345391ee11e89602e0ebf09d1d536fc0f0140426676a23a16
7
- data.tar.gz: 3ae857f6940bf2b871b123d88c3caf3e12589efe3b2a3733cb723b73cdca347c3bfa8506ecf3c931d0d83d7dd99882bf25a9abe7efa3e85f76e4958b111be312
6
+ metadata.gz: 5d6aba7952cc495dd4b74ea0aa452f473e01dc6a6bda2a94d1238d9102f5b448de2b4ae1765f87e2020d2fb460b1613044fe9403f0019b54b050b3a48261b425
7
+ data.tar.gz: 21ddb1d302b53049a605052d67bbdf2d51bee9a4e80f82c6c10f689d5b292397e0d913dae4b05e66c93ee4c93d831e14e885c448a77b049dab48ac1bda3b84b5
data/.copier-answers.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # See the project for instructions on how to update the project
4
4
  #
5
5
  # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
6
- _commit: v1.48.0
6
+ _commit: v1.50.0
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
9
  gitlab_namespace: gitlab-org/ruby/gems
@@ -11,6 +11,7 @@ golang: false
11
11
  helm: false
12
12
  initial_codeowners: '@reprazent @andrewn @mkaeppler @ayufan'
13
13
  jsonnet: false
14
+ measure_performance: false
14
15
  project_name: labkit-ruby
15
16
  release_platform: false
16
17
  ruby: true
@@ -0,0 +1,5 @@
1
+ # This file should contain dependency versions not managed directly by mise
2
+ # Use Renovate annotation comments to manage dependencies. Renovate is configured
3
+ # to read this file automatically.
4
+
5
+ variables: {} # None yet...
data/.gitlab-ci.yml CHANGED
@@ -32,7 +32,7 @@ include:
32
32
  # so this augments the base job (and is inherited by anything that extends it).
33
33
  rspec:
34
34
  services:
35
- - name: redis:7-alpine
35
+ - name: redis:8-alpine
36
36
  alias: redis
37
37
  variables:
38
38
  LABKIT_TEST_REDIS_URL: redis://redis
data/docker-compose.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  services:
2
2
  redis:
3
- image: redis:7-alpine
3
+ image: redis:8-alpine
4
4
  ports:
5
5
  - "6390:6379"
6
6
  healthcheck:
@@ -22,17 +22,17 @@ Gem::Specification.new do |spec|
22
22
  spec.required_ruby_version = ">= 3.3", "< 5"
23
23
 
24
24
  # Please maintain alphabetical order for dependencies
25
- spec.add_runtime_dependency "actionpack", ">= 5.0.0", "< 8.1.0"
26
- spec.add_runtime_dependency "activesupport", ">= 5.0.0", "< 8.1.0"
25
+ spec.add_runtime_dependency "actionpack", ">= 5.0.0", "< 8.2.0"
26
+ spec.add_runtime_dependency "activesupport", ">= 5.0.0", "< 8.2.0"
27
27
  spec.add_runtime_dependency "grpc", ">= 1.75" # Be sure to update the "grpc-tools" dev_dependency too
28
28
  spec.add_runtime_dependency "google-protobuf", ">= 3.25", "< 5.0"
29
29
  spec.add_runtime_dependency "jaeger-client", "~> 1.1.0"
30
30
  spec.add_runtime_dependency "json_schemer", ">= 2.3.0", "< 3.0"
31
31
  spec.add_runtime_dependency "openssl", "~> 3.3.2"
32
- spec.add_runtime_dependency "opentelemetry-sdk", "~> 1.10"
33
- spec.add_runtime_dependency "opentelemetry-instrumentation-all", "~> 0.89.1"
34
- spec.add_runtime_dependency "opentelemetry-exporter-otlp", "~> 0.31.1"
35
- spec.add_runtime_dependency "opentracing", "~> 0.4"
32
+ spec.add_runtime_dependency "opentelemetry-sdk", ">= 1.10", "< 2"
33
+ spec.add_runtime_dependency "opentelemetry-instrumentation-all", ">= 0.89", "< 1"
34
+ spec.add_runtime_dependency "opentelemetry-exporter-otlp", ">= 0.31", "< 1"
35
+ spec.add_runtime_dependency "opentracing", ">= 0.4", "< 1"
36
36
  spec.add_runtime_dependency "pg_query", ">= 6.1.0", "< 7.0"
37
37
  spec.add_runtime_dependency "prometheus-client-mmap", ">= 1.2", "< 2.0"
38
38
  spec.add_runtime_dependency "redis", "> 3.0.0", "< 6.0.0"
@@ -49,7 +49,7 @@ Gem::Specification.new do |spec|
49
49
  spec.add_development_dependency "pry", "~> 0.12"
50
50
  spec.add_development_dependency "pry-byebug", "~> 3.11"
51
51
  spec.add_development_dependency "rack", "~> 2.0"
52
- spec.add_development_dependency "railties", ">= 5.0.0", "< 8.1.0"
52
+ spec.add_development_dependency "railties", ">= 5.0.0", "< 8.2.0"
53
53
  spec.add_development_dependency "rake", "~> 13.2"
54
54
  spec.add_development_dependency "rest-client", "~> 2.1.0"
55
55
  spec.add_development_dependency "rspec", "~> 3.12.0"
@@ -38,6 +38,26 @@ module Labkit
38
38
  return {count, redis.call('TTL', KEYS[1])}
39
39
  LUA
40
40
 
41
+ # Atomic SADD + SCARD + conditional EXPIRE. SET-cardinality counterpart
42
+ # of INCR_SCRIPT; same shape (read TTL, mutate, set TTL when missing,
43
+ # return post-state {count, TTL}). count is SCARD, not the SADD return.
44
+ #
45
+ # ttl_before < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry),
46
+ # so this also self-heals orphan keys left without TTL.
47
+ SADD_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
48
+ local ttl = ARGV[1]
49
+ local member = ARGV[2]
50
+ local ttl_before = redis.call('TTL', KEYS[1])
51
+
52
+ redis.call('SADD', KEYS[1], member)
53
+ local count = redis.call('SCARD', KEYS[1])
54
+ if ttl_before < 0 then
55
+ redis.call('EXPIRE', KEYS[1], ttl)
56
+ end
57
+
58
+ return {count, redis.call('TTL', KEYS[1])}
59
+ LUA
60
+
41
61
  def initialize(name:, rules:, redis:, logger:)
42
62
  @name = name
43
63
  @rules = rules
@@ -70,10 +90,21 @@ module Labkit
70
90
 
71
91
  # :log rules are non-terminating: they emit metrics and continue,
72
92
  # so a shadow :log rule cannot disable a following :block rule.
93
+ #
94
+ # SET-mode rules (rule.count_distinct set) that match but whose identifier
95
+ # is missing the count_distinct key fail open + log + bump errors_total, and
96
+ # the loop continues to the next rule (the rule is treated as not applicable
97
+ # rather than aborting the whole evaluation).
73
98
  def check_rules(identifier, cost)
74
99
  @rules.each do |rule|
75
100
  next unless rule_matches?(rule, identifier)
76
101
 
102
+ if rule.count_distinct && missing_count_distinct_value?(rule, identifier)
103
+ log_missing_count_distinct(rule, identifier)
104
+ report_error_metrics
105
+ next
106
+ end
107
+
77
108
  result = evaluate_rule(rule, identifier, cost)
78
109
  report_matched_metrics(result)
79
110
  return result unless rule.action == :log
@@ -85,6 +116,10 @@ module Labkit
85
116
 
86
117
  # Mirror of check_rules without metrics: peek skips :log rules (their state
87
118
  # is unobservable through peek).
119
+ #
120
+ # peek does not need the count_distinct identifier key - it reads SCARD on
121
+ # the rule-keyed compound key, which contains the cardinality across all
122
+ # members. So missing-key fail-open does not apply here.
88
123
  def peek_rules(identifier)
89
124
  @rules.each do |rule|
90
125
  next if rule.action == :log
@@ -100,12 +135,25 @@ module Labkit
100
135
  rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
101
136
  end
102
137
 
138
+ def missing_count_distinct_value?(rule, identifier)
139
+ value = identifier[rule.count_distinct]
140
+ value.nil? || value.to_s.empty?
141
+ end
142
+
103
143
  def evaluate_rule(rule, identifier, cost)
104
144
  redis_key = build_redis_key(rule, identifier)
105
145
  resolved_limit = Integer(resolve_value(rule.limit))
106
146
  resolved_period = Integer(resolve_value(rule.period))
107
147
 
108
- count, ttl = incr_with_ttl(redis_key, resolved_period, cost)
148
+ # cost is ignored for count_distinct rules: SADD is binary (a member is
149
+ # either added or not), and the post-add count is SCARD regardless.
150
+ count, ttl =
151
+ if rule.count_distinct
152
+ sadd_with_ttl(redis_key, identifier[rule.count_distinct], resolved_period)
153
+ else
154
+ incr_with_ttl(redis_key, resolved_period, cost)
155
+ end
156
+
109
157
  build_result(rule, resolved_limit, resolved_period, count, ttl)
110
158
  end
111
159
 
@@ -114,7 +162,7 @@ module Labkit
114
162
  resolved_limit = Integer(resolve_value(rule.limit))
115
163
  resolved_period = Integer(resolve_value(rule.period))
116
164
 
117
- count, ttl = read_with_ttl(redis_key)
165
+ count, ttl = rule.count_distinct ? scard_with_ttl(redis_key) : read_with_ttl(redis_key)
118
166
  build_result(rule, resolved_limit, resolved_period, count, ttl)
119
167
  end
120
168
 
@@ -168,7 +216,7 @@ module Labkit
168
216
  # otherwise.
169
217
  def incr_with_ttl(redis_key, period, cost)
170
218
  @redis.with do |conn|
171
- raw_count, ttl = eval_incr_script(conn, redis_key, period, cost)
219
+ raw_count, ttl = INCR_SCRIPT.eval(conn, keys: [redis_key], argv: [period, cost])
172
220
  [Float(raw_count), ttl]
173
221
  end
174
222
  end
@@ -191,8 +239,29 @@ module Labkit
191
239
  end
192
240
  end
193
241
 
194
- def eval_incr_script(conn, redis_key, period, cost)
195
- INCR_SCRIPT.eval(conn, keys: [redis_key], argv: [period, cost])
242
+ # Atomic SADD + SCARD + conditional EXPIRE in one Redis operation via Lua.
243
+ # See SADD_SCRIPT for the body. Mirrors incr_with_ttl's shape, including
244
+ # the Float-typed count for uniformity with the INCR path.
245
+ def sadd_with_ttl(redis_key, member, period)
246
+ member_str = encode_char_value(member.to_s)
247
+ @redis.with do |conn|
248
+ raw_count, ttl = SADD_SCRIPT.eval(conn, keys: [redis_key], argv: [period, member_str])
249
+ [Float(raw_count), ttl]
250
+ end
251
+ end
252
+
253
+ # Pipelined SCARD + TTL. SCARD on a missing key returns 0, so no
254
+ # explicit nil handling is needed (unlike GET in read_with_ttl).
255
+ # SCARD is integer-valued; coerced to Float for type uniformity with
256
+ # the INCR path so callers see a consistent count type.
257
+ def scard_with_ttl(redis_key)
258
+ @redis.with do |conn|
259
+ scard, ttl = conn.pipelined do |pipe|
260
+ pipe.scard(redis_key)
261
+ pipe.ttl(redis_key)
262
+ end
263
+ [Float(scard), ttl]
264
+ end
196
265
  end
197
266
 
198
267
  def log_error(error, identifier)
@@ -204,6 +273,16 @@ module Labkit
204
273
  )
205
274
  end
206
275
 
276
+ def log_missing_count_distinct(rule, identifier)
277
+ @logger.warn(
278
+ message: "rate_limit_missing_count_distinct",
279
+ name: @name,
280
+ rule: rule.name,
281
+ count_distinct: rule.count_distinct.to_s,
282
+ identifier: identifier&.to_h
283
+ )
284
+ end
285
+
207
286
  def report_matched_metrics(result)
208
287
  Metrics.calls_total.increment(
209
288
  rate_limiter: @name,
@@ -17,12 +17,32 @@ module Labkit
17
17
  # (count but always permit; terminates evaluation on match
18
18
  # regardless of whether the limit was exceeded)
19
19
  # characteristics - identifier keys used to build the compound Redis counter key
20
+ # count_distinct - optional Symbol naming an identifier key. When set, the rule
21
+ # counts the number of distinct values seen for that key within
22
+ # the (characteristics-bucketed) period, backed by a Redis SET.
23
+ # When nil (default), the rule counts the number of calls,
24
+ # backed by INCR. The named key must not overlap +characteristics+.
20
25
  #
21
26
  # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
22
27
  # characters. It is used as the middle segment of every Redis counter key for
23
28
  # this rule, so changing a rule's name mid-window abandons its in-flight counters.
24
- Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics) do
25
- def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block)
29
+ Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics, :count_distinct) do
30
+ def self.normalize_count_distinct(value, characteristics_arr)
31
+ sym =
32
+ case value
33
+ when nil then nil
34
+ when Symbol then value
35
+ when String then value.to_sym
36
+ else
37
+ raise ArgumentError, "count_distinct must be a Symbol, String, or nil, got #{value.class}"
38
+ end
39
+
40
+ raise ArgumentError, "count_distinct #{sym.inspect} must not overlap characteristics #{characteristics_arr.inspect}" if sym && characteristics_arr.include?(sym)
41
+
42
+ sym
43
+ end
44
+
45
+ def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block, count_distinct: nil)
26
46
  raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)
27
47
 
28
48
  name_str = name.to_s
@@ -36,13 +56,16 @@ module Labkit
36
56
  raise ArgumentError, "Rule name too long: #{name.inspect}. Maximum 64 characters" if name_str.length > RULE_NAME_MAX_LENGTH
37
57
  end
38
58
 
59
+ characteristics_arr = Array(characteristics).map(&:to_sym).freeze
60
+
39
61
  super(
40
62
  name: name_str.freeze,
41
63
  match: match.transform_keys(&:to_sym).transform_values { |v| Matcher.build(v) }.freeze,
42
64
  limit: limit,
43
65
  period: period,
44
66
  action: action_sym,
45
- characteristics: Array(characteristics).map(&:to_sym).freeze
67
+ characteristics: characteristics_arr,
68
+ count_distinct: self.class.normalize_count_distinct(count_distinct, characteristics_arr)
46
69
  )
47
70
  end
48
71
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -18,7 +18,7 @@ dependencies:
18
18
  version: 5.0.0
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
- version: 8.1.0
21
+ version: 8.2.0
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -28,7 +28,7 @@ dependencies:
28
28
  version: 5.0.0
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
- version: 8.1.0
31
+ version: 8.2.0
32
32
  - !ruby/object:Gem::Dependency
33
33
  name: activesupport
34
34
  requirement: !ruby/object:Gem::Requirement
@@ -38,7 +38,7 @@ dependencies:
38
38
  version: 5.0.0
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
- version: 8.1.0
41
+ version: 8.2.0
42
42
  type: :runtime
43
43
  prerelease: false
44
44
  version_requirements: !ruby/object:Gem::Requirement
@@ -48,7 +48,7 @@ dependencies:
48
48
  version: 5.0.0
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
- version: 8.1.0
51
+ version: 8.2.0
52
52
  - !ruby/object:Gem::Dependency
53
53
  name: grpc
54
54
  requirement: !ruby/object:Gem::Requirement
@@ -135,58 +135,82 @@ dependencies:
135
135
  name: opentelemetry-sdk
136
136
  requirement: !ruby/object:Gem::Requirement
137
137
  requirements:
138
- - - "~>"
138
+ - - ">="
139
139
  - !ruby/object:Gem::Version
140
140
  version: '1.10'
141
+ - - "<"
142
+ - !ruby/object:Gem::Version
143
+ version: '2'
141
144
  type: :runtime
142
145
  prerelease: false
143
146
  version_requirements: !ruby/object:Gem::Requirement
144
147
  requirements:
145
- - - "~>"
148
+ - - ">="
146
149
  - !ruby/object:Gem::Version
147
150
  version: '1.10'
151
+ - - "<"
152
+ - !ruby/object:Gem::Version
153
+ version: '2'
148
154
  - !ruby/object:Gem::Dependency
149
155
  name: opentelemetry-instrumentation-all
150
156
  requirement: !ruby/object:Gem::Requirement
151
157
  requirements:
152
- - - "~>"
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0.89'
161
+ - - "<"
153
162
  - !ruby/object:Gem::Version
154
- version: 0.89.1
163
+ version: '1'
155
164
  type: :runtime
156
165
  prerelease: false
157
166
  version_requirements: !ruby/object:Gem::Requirement
158
167
  requirements:
159
- - - "~>"
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0.89'
171
+ - - "<"
160
172
  - !ruby/object:Gem::Version
161
- version: 0.89.1
173
+ version: '1'
162
174
  - !ruby/object:Gem::Dependency
163
175
  name: opentelemetry-exporter-otlp
164
176
  requirement: !ruby/object:Gem::Requirement
165
177
  requirements:
166
- - - "~>"
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0.31'
181
+ - - "<"
167
182
  - !ruby/object:Gem::Version
168
- version: 0.31.1
183
+ version: '1'
169
184
  type: :runtime
170
185
  prerelease: false
171
186
  version_requirements: !ruby/object:Gem::Requirement
172
187
  requirements:
173
- - - "~>"
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0.31'
191
+ - - "<"
174
192
  - !ruby/object:Gem::Version
175
- version: 0.31.1
193
+ version: '1'
176
194
  - !ruby/object:Gem::Dependency
177
195
  name: opentracing
178
196
  requirement: !ruby/object:Gem::Requirement
179
197
  requirements:
180
- - - "~>"
198
+ - - ">="
181
199
  - !ruby/object:Gem::Version
182
200
  version: '0.4'
201
+ - - "<"
202
+ - !ruby/object:Gem::Version
203
+ version: '1'
183
204
  type: :runtime
184
205
  prerelease: false
185
206
  version_requirements: !ruby/object:Gem::Requirement
186
207
  requirements:
187
- - - "~>"
208
+ - - ">="
188
209
  - !ruby/object:Gem::Version
189
210
  version: '0.4'
211
+ - - "<"
212
+ - !ruby/object:Gem::Version
213
+ version: '1'
190
214
  - !ruby/object:Gem::Dependency
191
215
  name: pg_query
192
216
  requirement: !ruby/object:Gem::Requirement
@@ -410,7 +434,7 @@ dependencies:
410
434
  version: 5.0.0
411
435
  - - "<"
412
436
  - !ruby/object:Gem::Version
413
- version: 8.1.0
437
+ version: 8.2.0
414
438
  type: :development
415
439
  prerelease: false
416
440
  version_requirements: !ruby/object:Gem::Requirement
@@ -420,7 +444,7 @@ dependencies:
420
444
  version: 5.0.0
421
445
  - - "<"
422
446
  - !ruby/object:Gem::Version
423
- version: 8.1.0
447
+ version: 8.2.0
424
448
  - !ruby/object:Gem::Dependency
425
449
  name: rake
426
450
  requirement: !ruby/object:Gem::Requirement
@@ -523,6 +547,7 @@ files:
523
547
  - ".env.example.sh"
524
548
  - ".gitignore"
525
549
  - ".gitlab-ci-asdf-versions.yml"
550
+ - ".gitlab-ci-other-versions.yml"
526
551
  - ".gitlab-ci.yml"
527
552
  - ".gitlab/CODEOWNERS"
528
553
  - ".gitlab/merge_request_templates/default.md"