gitlab-labkit 1.22.0 → 2.0.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: 88d5f5a7cd756153b8c69edbf0a887b0d7fbd98c0eab2828c3150cc2fafa1fac
4
- data.tar.gz: 0bf07e6f00b14c3a31ba3dab3b3bbd7cd72af81b61dd66992e9d9ef6bd73d6da
3
+ metadata.gz: 0c6ab89c2e34ad08721cee1e920601a8d6d039bfe8a6552777021714f17d0c74
4
+ data.tar.gz: 6b9e36ac9ce69866890d6df28132d8abd280cf7d3b4ab7b748470864dbf8a70e
5
5
  SHA512:
6
- metadata.gz: 737e6ee6b0fc00f716cf3aba4f94bd02c65c01dc5436ca8a4b99ce64ce092efe8bd39af8fe7d6d68ce5e8c2dcd1ba7c991d49b4c0c742c17a4e4ea1d48e50f1b
7
- data.tar.gz: 17db3fe540d65f81193adfc8f86d9e984761b76b17f639bad7cd73df46206035690b1c3466bc8d54c26b7c7a3f043c6158edb657137c67b9318098b0eae61f48
6
+ metadata.gz: af23293cd5d9afe2dd3ba93ca4cd98a6ae5178c04079069e60defff46652266158fc7d74fe335d9345391ee11e89602e0ebf09d1d536fc0f0140426676a23a16
7
+ data.tar.gz: 3ae857f6940bf2b871b123d88c3caf3e12589efe3b2a3733cb723b73cdca347c3bfa8506ecf3c931d0d83d7dd99882bf25a9abe7efa3e85f76e4958b111be312
data/lib/gitlab-labkit.rb CHANGED
@@ -25,6 +25,7 @@ module Labkit
25
25
  autoload :Middleware, "labkit/middleware"
26
26
  autoload :Fields, "labkit/fields"
27
27
  autoload :RateLimit, "labkit/rate_limit"
28
+ autoload :Redis, "labkit/redis"
28
29
 
29
30
  # Publishers to publish notifications whenever a HTTP reqeust is made.
30
31
  # A broadcasted notification's payload in topic "request.external_http" includes:
@@ -12,6 +12,32 @@ module Labkit
12
12
  CHAR_VALUE_MAX_LENGTH = 200
13
13
  MISSING_VALUE_SENTINEL = "_unknown_"
14
14
 
15
+ # Atomic increment-with-TTL Lua script. The whole script runs as one
16
+ # operation from Redis's perspective, so there is no window between
17
+ # the increment and EXPIRE that can leak a key without TTL.
18
+ #
19
+ # INCRBYFLOAT serves both count-mode (cost=1, equivalent to INCR for
20
+ # integer-encoded keys) and cost-mode callers, so a single script
21
+ # handles every rule shape. cost=0 also flows through INCRBYFLOAT;
22
+ # Redis treats the result as a no-op on the stored value while
23
+ # still observing the post-state count and TTL we return.
24
+ #
25
+ # ttl_before < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
26
+ # The -1 case shouldn't arise with the atomic script, but self-healing
27
+ # recovers keys left without TTL by any prior bug.
28
+ INCR_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
29
+ local ttl = ARGV[1]
30
+ local cost = tonumber(ARGV[2])
31
+ local ttl_before = redis.call('TTL', KEYS[1])
32
+
33
+ local count = redis.call('INCRBYFLOAT', KEYS[1], cost)
34
+ if ttl_before < 0 then
35
+ redis.call('EXPIRE', KEYS[1], ttl)
36
+ end
37
+
38
+ return {count, redis.call('TTL', KEYS[1])}
39
+ LUA
40
+
15
41
  def initialize(name:, rules:, redis:, logger:)
16
42
  @name = name
17
43
  @rules = rules
@@ -19,8 +45,8 @@ module Labkit
19
45
  @logger = logger
20
46
  end
21
47
 
22
- def check(identifier)
23
- check_rules(identifier)
48
+ def check(identifier, cost: 1)
49
+ check_rules(identifier, cost)
24
50
  rescue StandardError => e
25
51
  # Intentionally broad: fail-open applies to any unexpected error (network,
26
52
  # timeout, OOM) not only Redis protocol errors.
@@ -44,11 +70,11 @@ module Labkit
44
70
 
45
71
  # :log rules are non-terminating: they emit metrics and continue,
46
72
  # so a shadow :log rule cannot disable a following :block rule.
47
- def check_rules(identifier)
73
+ def check_rules(identifier, cost)
48
74
  @rules.each do |rule|
49
75
  next unless rule_matches?(rule, identifier)
50
76
 
51
- result = evaluate_rule(rule, identifier)
77
+ result = evaluate_rule(rule, identifier, cost)
52
78
  report_matched_metrics(result)
53
79
  return result unless rule.action == :log
54
80
  end
@@ -74,12 +100,12 @@ module Labkit
74
100
  rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
75
101
  end
76
102
 
77
- def evaluate_rule(rule, identifier)
103
+ def evaluate_rule(rule, identifier, cost)
78
104
  redis_key = build_redis_key(rule, identifier)
79
105
  resolved_limit = Integer(resolve_value(rule.limit))
80
106
  resolved_period = Integer(resolve_value(rule.period))
81
107
 
82
- count, ttl = incr_with_ttl(redis_key, resolved_period)
108
+ count, ttl = incr_with_ttl(redis_key, resolved_period, cost)
83
109
  build_result(rule, resolved_limit, resolved_period, count, ttl)
84
110
  end
85
111
 
@@ -133,17 +159,17 @@ module Labkit
133
159
  end
134
160
  end
135
161
 
136
- # Pipelines INCR and TTL so both are fetched in a single round-trip.
137
- # EXPIRE follows as a separate call only on first write (count == 1).
138
- # On first write TTL will be -1 (expiry not yet set); callers fall back to period.
139
- def incr_with_ttl(redis_key, period)
162
+ # Atomically increments the counter by `cost`, sets the TTL on first
163
+ # write, and reads back the post-increment TTL, all in one Redis
164
+ # operation via Lua. See INCR_SCRIPT for the script body.
165
+ #
166
+ # count is parsed as Float because INCRBYFLOAT returns a string-encoded
167
+ # number; the Float is integer-valued when cost is 1, fractional
168
+ # otherwise.
169
+ def incr_with_ttl(redis_key, period, cost)
140
170
  @redis.with do |conn|
141
- count, ttl = conn.pipelined do |pipe|
142
- pipe.incr(redis_key)
143
- pipe.ttl(redis_key)
144
- end
145
- conn.expire(redis_key, period) if count == 1
146
- [count, ttl]
171
+ raw_count, ttl = eval_incr_script(conn, redis_key, period, cost)
172
+ [Float(raw_count), ttl]
147
173
  end
148
174
  end
149
175
 
@@ -151,17 +177,24 @@ module Labkit
151
177
  # A missing key (GET => nil, TTL => -2) is reported as count=0; the
152
178
  # build_result fallback then derives reset_at from the rule period
153
179
  # since there is no Redis-side window to read.
180
+ #
181
+ # Float parsing accepts both INCR-stored ("5") and INCRBYFLOAT-stored
182
+ # ("5.7") values uniformly.
154
183
  def read_with_ttl(redis_key)
155
184
  @redis.with do |conn|
156
185
  raw_count, ttl = conn.pipelined do |pipe|
157
186
  pipe.get(redis_key)
158
187
  pipe.ttl(redis_key)
159
188
  end
160
- count = raw_count.nil? ? 0 : Integer(raw_count)
189
+ count = raw_count.nil? ? 0.0 : Float(raw_count)
161
190
  [count, ttl]
162
191
  end
163
192
  end
164
193
 
194
+ def eval_incr_script(conn, redis_key, period, cost)
195
+ INCR_SCRIPT.eval(conn, keys: [redis_key], argv: [period, cost])
196
+ end
197
+
165
198
  def log_error(error, identifier)
166
199
  @logger.warn(
167
200
  message: "rate_limit_error",
@@ -32,10 +32,13 @@ module Labkit
32
32
  end
33
33
 
34
34
  # @param identifier [Identifier, Hash] caller attributes for this request
35
+ # @param cost [Numeric] amount to add to the counter. Defaults to 1
36
+ # (count-mode). Pass a non-1 Numeric for cost-mode counters such as
37
+ # resource-usage limits; passing 0 reads the counter without writing.
35
38
  # @return [Result]
36
- def check(identifier)
39
+ def check(identifier, cost: 1)
37
40
  id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
38
- @evaluator.check(id)
41
+ @evaluator.check(id, cost: cost)
39
42
  end
40
43
 
41
44
  # Read the current rate-limit state without incrementing the counter.
@@ -33,13 +33,14 @@ module Labkit
33
33
 
34
34
  # Returns RFC-compliant rate limit response headers, or {} when no rule matched or an error occurred.
35
35
  # Keys: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (Unix timestamp).
36
- # reset_at is advisory only - derived from a pipelined redis.ttl call, not fully atomic.
36
+ # remaining is coerced to Integer for header output even when info.remaining is fractional;
37
+ # the RateLimit header spec requires integer values.
37
38
  def to_response_headers
38
39
  return {} unless matched? && !error? && info
39
40
 
40
41
  {
41
- "RateLimit-Limit" => info.resolved_limit.to_s,
42
- "RateLimit-Remaining" => info.remaining.to_s,
42
+ "RateLimit-Limit" => info.resolved_limit.to_i.to_s,
43
+ "RateLimit-Remaining" => info.remaining.to_i.to_s,
43
44
  "RateLimit-Reset" => info.reset_at.to_i.to_s
44
45
  }
45
46
  end
@@ -48,8 +49,12 @@ module Labkit
48
49
  # Per-window counter data attached to a matched Result.
49
50
  # resolved_limit - the evaluated limit Integer for this rule
50
51
  # resolved_period - the evaluated period Integer (seconds) for this rule
51
- # count - the raw INCR value; useful for utilization-ratio metrics
52
- # remaining - requests remaining before the limit is hit (floors at 0)
52
+ # count - the post-increment counter value as a Float; integer-valued
53
+ # for default cost=1 callers, fractional for cost-mode callers.
54
+ # Pre-2.x releases exposed this as Integer; see the migration
55
+ # note in the cost-aware Lua script change.
56
+ # remaining - requests remaining before the limit is hit (floors at 0).
57
+ # Inherits Float typing from count when count is fractional.
53
58
  # reset_at - best-effort UTC Time when the counter window resets
54
59
  Result::Info = Data.define(:resolved_limit, :resolved_period, :count, :remaining, :reset_at)
55
60
  end
@@ -43,9 +43,10 @@ module Labkit
43
43
  # @param rules [Array<Rule>] ordered list of rules (first match wins)
44
44
  # @param redis [Object, nil] Redis client; falls back to config.redis
45
45
  # @param logger [Logger, nil] logger; falls back to config.logger
46
+ # @param cost [Numeric] amount to add to the counter; see Limiter#check
46
47
  # @return [Result]
47
- def check(name:, identifier:, rules:, redis: nil, logger: nil)
48
- Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check(identifier)
48
+ def check(name:, identifier:, rules:, redis: nil, logger: nil, cost: 1)
49
+ Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check(identifier, cost: cost)
49
50
  end
50
51
  end
51
52
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "redis"
5
+
6
+ module Labkit
7
+ module Redis
8
+ # Wraps a Lua script for EVALSHA-with-NOSCRIPT-fallback execution.
9
+ # The SHA is computed once at construction. Redis caches the script
10
+ # body the first time EVAL is invoked; subsequent EVALSHA calls hit
11
+ # that cache. SCRIPT FLUSH or a Redis restart drops the cache; the
12
+ # NOSCRIPT recovery re-ships the body and re-populates it.
13
+ #
14
+ # @example
15
+ # SCRIPT = Labkit::Redis::Script.new(<<~LUA)
16
+ # return redis.call('INCRBY', KEYS[1], ARGV[1])
17
+ # LUA
18
+ #
19
+ # pool.with { |conn| SCRIPT.eval(conn, keys: ["counter"], argv: [1]) }
20
+ class Script
21
+ attr_reader :body, :sha
22
+
23
+ def initialize(body)
24
+ @body = body.freeze
25
+ # SHA1 is mandated by the Redis EVALSHA wire protocol, not a discretionary hash choice.
26
+ @sha = OpenSSL::Digest::SHA1.hexdigest(body).freeze # rubocop:disable Fips/SHA1
27
+ freeze
28
+ end
29
+
30
+ # @param conn a Redis client (the connection checked out of a pool)
31
+ # @param keys [Array] KEYS arguments to the Lua script
32
+ # @param argv [Array] ARGV arguments to the Lua script
33
+ # @return the script's return value
34
+ def eval(conn, keys:, argv:)
35
+ conn.evalsha(@sha, keys: keys, argv: argv)
36
+ rescue ::Redis::CommandError => e
37
+ raise unless e.message.start_with?("NOSCRIPT")
38
+
39
+ conn.eval(@body, keys: keys, argv: argv)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ # Redis utilities shared across Labkit (script execution, key helpers, etc.).
5
+ module Redis
6
+ autoload :Script, "labkit/redis/script"
7
+ end
8
+ 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: 1.22.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -609,6 +609,8 @@ files:
609
609
  - lib/labkit/rate_limit/metrics.rb
610
610
  - lib/labkit/rate_limit/result.rb
611
611
  - lib/labkit/rate_limit/rule.rb
612
+ - lib/labkit/redis.rb
613
+ - lib/labkit/redis/script.rb
612
614
  - lib/labkit/rspec/README.md
613
615
  - lib/labkit/rspec/matchers.rb
614
616
  - lib/labkit/rspec/matchers/user_experience_matchers.rb