gitlab-labkit 2.8.1 → 3.0.1

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: 7ad3bb9ba369010544041c2ab2d2ec2d8610c535c5b2c5e592fe0e8313c56a66
4
- data.tar.gz: 4fa90bdb331f44920cc9969603db7189e42dec21c7fab1d7593e91455de323c3
3
+ metadata.gz: 5fbd752b05c1341ef1292c7a3a1ae3555dd27230d7afc830e0c713547bdd02ee
4
+ data.tar.gz: 6dc8eab0332b9b6851627fe83a95bfb0bacefbe3222d712e28a8e210c19fdb8a
5
5
  SHA512:
6
- metadata.gz: 21c914360a54bb30e914e7b6771781657463b3febd83b81634b88513f2986d7d9f25016bae84781b69918429a49c13fdfdf7da8581d385bc13adb698c8728f1d
7
- data.tar.gz: e8b1b9f46c438e2608a1f12e55d2fd3f518586795dfb0327b08fed8bcd6496525fb0fba71c5212fdab45b45a3ca1b3b9c792dc212a838a98124be37d698fd823
6
+ metadata.gz: 56316fcea7b575a62119a814f3e3b4c85dc8d5e93ed72e2316bd47388bd142dd44ae21f07f2b6f40d070573c04bf6b79c26e14601f90e0a2f6fcf263fa05ce36
7
+ data.tar.gz: 65e7e06919637356e25ab5d6f56880df2eb7e340474afeaf01212fe55612439d8bba8707d2d1bc835c864723cd0d15cca135065756ce3f17a9bd959f253775f3
@@ -15,7 +15,7 @@ flowchart LR
15
15
  App[Application code] -->|"check(identifier)"| Limiter
16
16
  Limiter -->|delegates| Evaluator
17
17
  Evaluator -->|iterates ordered| Rules[Rule list]
18
- Evaluator <-->|INCR / TTL / EXPIRE| Redis[(Redis)]
18
+ Evaluator <-->|EVALSHA / GET / TTL| Redis[(Redis)]
19
19
  Evaluator -->|emits| Metrics[Prometheus metrics]
20
20
  Evaluator -->|returns| Result
21
21
  Result --> App
@@ -146,7 +146,7 @@ A `Rule` is a `Data.define` value object with the following fields:
146
146
  | `match` | Hash of identifier key/value predicates that must **all** be satisfied for the rule to apply. Empty hash matches anything. See [Matchers](#matchers). |
147
147
  | `limit` | Integer request threshold per `period`. May be a callable resolved on every check. |
148
148
  | `period` | Window length in seconds. May be a callable resolved on every check. |
149
- | `action` | What the result reports when the limit is exceeded. One of `:block`, `:log`, `:allow`, `:skip`. Default `:block`. See [Actions](#actions). |
149
+ | `action` | What the result reports when the limit is exceeded. One of `:block`, `:log`, `:skip`. Default `:block`. See [Actions](#actions). |
150
150
  | `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
151
151
 
152
152
  Making `limit` or `period` callable is the supported pattern for
@@ -198,7 +198,7 @@ flowchart TD
198
198
  Build --> Emit[Emit calls_total + limit/period gauges]
199
199
  Emit --> Act{rule.action}
200
200
  Act -->|":log<br/>(non-terminating)"| Iter
201
- Act -->|:block or :allow| Return([Return Result])
201
+ Act -->|:block| Return([Return Result])
202
202
  Iter -->|no more rules| Unmatched[Emit calls_total<br/>rule=unmatched, action=allow]
203
203
  Unmatched --> ReturnUnmatched([Return matched=false<br/>action=:allow])
204
204
  Eval -. StandardError .-> Error[Emit errors_total<br/>log warn]
@@ -219,17 +219,12 @@ which never touch Redis:
219
219
  together and the `:log` rule cannot disable the `:block` rule. Note that a
220
220
  pure `:log`-only check still emits one `rule="unmatched"` metric entry
221
221
  because no terminating rule fired.
222
- - `:allow` — when exceeded, `Result#action` is `:allow` (rather than
223
- `:block`). Useful for "always allow this caller even if they're over the
224
- limit" cases while still observing them via metrics. Evaluation terminates
225
- on the first match.
226
222
  - `:skip` — bypass. A matching rule terminates evaluation with
227
223
  `Result#action` `:allow` **without any Redis operation**: nothing is
228
224
  counted, so `limit`, `period`, `characteristics`, and `count_distinct` are
229
225
  inert and the result carries no `info` (`to_response_headers` is `{}`).
230
226
  The match is still observable via `calls_total{action="skip"}`. Use this
231
- for bypasses that don't need a counter; use `:allow` only when you want
232
- the bypassed traffic counted.
227
+ for bypasses.
233
228
 
234
229
  ### Redis keys
235
230
 
@@ -241,9 +236,14 @@ labkit:rl:<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]
241
236
 
242
237
  Characteristic values longer than 200 bytes are replaced with a SHA-256
243
238
  hexdigest to bound key length. Missing or empty characteristic values are
244
- encoded as `_unknown_`. The TTL is set on the first write of each window
245
- (`count == 1`) and is not extended on subsequent INCRs, so the window is a
246
- true fixed window starting at the first request, not a sliding window.
239
+ encoded as `_unknown_`. The TTL is set on the first write of each window and
240
+ is not extended on subsequent increments, so the window is a true fixed
241
+ window starting at the first request, not a sliding window.
242
+
243
+ A check is a single `EVALSHA` of `INCR_SCRIPT` (or `SADD_SCRIPT` for
244
+ `count_distinct` rules). Doing the whole read-modify-write inside Lua means
245
+ there is no window between the increment and the `EXPIRE` in which a key
246
+ could be left without a TTL.
247
247
 
248
248
  ```mermaid
249
249
  sequenceDiagram
@@ -251,24 +251,35 @@ sequenceDiagram
251
251
  participant E as Evaluator
252
252
  participant P as Connection pool
253
253
  participant R as Redis
254
+ participant L as INCR_SCRIPT (Lua)
254
255
 
255
256
  E->>P: pool.with { |conn| ... }
256
257
  P-->>E: conn
257
- E->>R: PIPELINE { INCR key, TTL key }
258
- R-->>E: [count, ttl]
259
- alt count == 1 (first write of window)
260
- E->>R: EXPIRE key period
261
- R-->>E: 1
262
- Note over E: ttl returned is -1 here;<br/>build_result falls back to<br/>resolved_period for reset_at.
263
- else count > 1
264
- Note over E: TTL is not extended:<br/>fixed window from first write.
258
+ E->>R: EVALSHA INCR_SCRIPT key, [period, cost]
259
+ R->>L: run script
260
+ L->>L: INCRBYFLOAT key cost
261
+ L->>L: TTL key
262
+ alt TTL < 0 (key was missing, or had no expiry)
263
+ L->>L: EXPIRE key period
264
+ Note over L: Returns period as the TTL:<br/>the window starts now.
265
+ else TTL >= 0 (window already running)
266
+ Note over L: TTL is not extended:<br/>fixed window from first write.
265
267
  end
268
+ L-->>R: {count, ttl}
269
+ R-->>E: [count, ttl]
266
270
  E-->>P: release conn
267
271
  ```
268
272
 
269
- `peek` follows the same shape but uses `GET` instead of `INCR` and never
270
- issues `EXPIRE`. A missing key (`GET nil`, `TTL -2`) is reported as
271
- `count = 0` and the window is treated as not-yet-started.
273
+ Because `INCRBYFLOAT` preserves an existing key's TTL, and creates a missing
274
+ key with no expiry, the TTL only needs reading once after the increment.
275
+ That single read distinguishes both cases the script must handle, so there is
276
+ no reason to read it again before mutating.
277
+
278
+ `peek` does not use a script: it pipelines `GET` + `TTL` (or `SCARD` + `TTL`)
279
+ and never issues `EXPIRE`, so it cannot start or extend a window. A missing
280
+ key (`GET → nil`, `TTL → -2`) is reported as `count = 0`, and `build_result`
281
+ falls back to the rule's period for `reset_at` since there is no Redis-side
282
+ window to read.
272
283
 
273
284
  ## Result
274
285
 
@@ -22,40 +22,42 @@ module Labkit
22
22
  # Redis treats the result as a no-op on the stored value while
23
23
  # still observing the post-state count and TTL we return.
24
24
  #
25
- # ttl_before < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
25
+ # ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
26
26
  # The -1 case shouldn't arise with the atomic script, but self-healing
27
27
  # recovers keys left without TTL by any prior bug.
28
28
  INCR_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
29
29
  local ttl = ARGV[1]
30
30
  local cost = tonumber(ARGV[2])
31
- local ttl_before = redis.call('TTL', KEYS[1])
32
31
 
33
32
  local count = redis.call('INCRBYFLOAT', KEYS[1], cost)
34
- if ttl_before < 0 then
33
+ local ttl_after = redis.call('TTL', KEYS[1])
34
+ if ttl_after < 0 then
35
35
  redis.call('EXPIRE', KEYS[1], ttl)
36
+ ttl_after = tonumber(ttl)
36
37
  end
37
38
 
38
- return {count, redis.call('TTL', KEYS[1])}
39
+ return {count, ttl_after}
39
40
  LUA
40
41
 
41
42
  # Atomic SADD + SCARD + conditional EXPIRE. SET-cardinality counterpart
42
- # of INCR_SCRIPT; same shape (read TTL, mutate, set TTL when missing,
43
+ # of INCR_SCRIPT; same shape (mutate, read TTL, set TTL when missing,
43
44
  # return post-state {count, TTL}). count is SCARD, not the SADD return.
44
45
  #
45
- # ttl_before < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry),
46
+ # ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry),
46
47
  # so this also self-heals orphan keys left without TTL.
47
48
  SADD_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
48
49
  local ttl = ARGV[1]
49
50
  local member = ARGV[2]
50
- local ttl_before = redis.call('TTL', KEYS[1])
51
51
 
52
52
  redis.call('SADD', KEYS[1], member)
53
53
  local count = redis.call('SCARD', KEYS[1])
54
- if ttl_before < 0 then
54
+ local ttl_after = redis.call('TTL', KEYS[1])
55
+ if ttl_after < 0 then
55
56
  redis.call('EXPIRE', KEYS[1], ttl)
57
+ ttl_after = tonumber(ttl)
56
58
  end
57
59
 
58
- return {count, redis.call('TTL', KEYS[1])}
60
+ return {count, ttl_after}
59
61
  LUA
60
62
 
61
63
  def initialize(name:, rules:, redis:, logger:)
@@ -247,7 +249,7 @@ module Labkit
247
249
  end
248
250
 
249
251
  # Atomically increments the counter by `cost`, sets the TTL on first
250
- # write, and reads back the post-increment TTL, all in one Redis
252
+ # write, and returns the window's remaining TTL, all in one Redis
251
253
  # operation via Lua. See INCR_SCRIPT for the script body.
252
254
  #
253
255
  # count is parsed as Float because INCRBYFLOAT returns a string-encoded
@@ -8,9 +8,9 @@ module Labkit
8
8
  # action - the outcome: what the caller should do
9
9
  # :block = rule matched, exceeded, rule configured to block
10
10
  # :log = rule matched, exceeded, rule configured to log only
11
- # :allow = rule matched but count within limit, rule configured to allow,
12
- # rule configured to skip (bypass, nothing counted),
13
- # no rule matched, or error (fail-open)
11
+ # :allow = rule matched but count within limit, rule configured
12
+ # to skip (bypass, nothing counted), no rule matched,
13
+ # or error (fail-open)
14
14
  # The rule's configured action is available via rule.action.
15
15
  # rule - the matched Rule object (nil when matched? is false)
16
16
  # error? - true if Redis was unavailable; result fails open (exceeded? is false)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Labkit
4
4
  module RateLimit
5
- KNOWN_ACTIONS = %i[block log allow skip].freeze
5
+ KNOWN_ACTIONS = %i[block log skip].freeze
6
6
  RULE_NAME_PATTERN = /\A[a-z0-9_]+\z/
7
7
  RULE_NAME_MAX_LENGTH = 64
8
8
 
@@ -13,9 +13,7 @@ module Labkit
13
13
  # limit - request threshold; may be a callable (resolved per check)
14
14
  # period - window in seconds; may be a callable (resolved per check)
15
15
  # action - :block (enforce), :log (count and log only, do not block,
16
- # evaluation continues to subsequent rules), :allow
17
- # (count but always permit; terminates evaluation on match
18
- # regardless of whether the limit was exceeded), or :skip
16
+ # evaluation continues to subsequent rules), or :skip
19
17
  # (bypass: permit and terminate evaluation on match without
20
18
  # counting; performs no Redis operation, so limit, period,
21
19
  # characteristics, and count_distinct are inert)
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.8.1
4
+ version: 3.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate