gitlab-labkit 5.8.0 → 5.9.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: bbffb0883d80c949bfb28ffec2943b9cc89ffb88c2c02656afe99ad777e0f152
4
- data.tar.gz: 93610eb46b3dbbfebe40c1b97d31d07b69af3f5e5ee0f152be29a66eca8da03e
3
+ metadata.gz: 23080f09f7295046f43c9519b76d3a71bef133c8178f60fb9d33bc13311c0947
4
+ data.tar.gz: 35ca7c5427293cad362e700551c1a6a33ba439866f8acd60e065944ab8085b6f
5
5
  SHA512:
6
- metadata.gz: c01235e9277e28adbde6cb2e14cedebbabe78b46a8f7e771664875da94cd59ea3c31e2033b152ea8e23dce3cd8ce84c0c5fd310c9691d1662006df0e87f66b67
7
- data.tar.gz: 87c05f1a0543e7969b35023d227d32cabc87455caa99aeca54a83832a5e40a2968895afb3982c4ecfcadc31d891558ce4abccb249f0758f95903367fd56b82df
6
+ metadata.gz: e83c05dc9f364acadcf7fa4c5343441476e8c4ab1fdb7b1ed36122499649caef0f6a6607d01e0c511042eb3f940fe4e799e0b422749ed28b8f8857ddc5c5fc6f
7
+ data.tar.gz: 0d9a4dccbb22b559ba33e2403151b4284b4e8240970384757ab1ec455d478bf8ad9865f7573883b7c11a819f68718c8111781e39856f6b4ad28f99dbf117e0a7
data/AGENTS.md CHANGED
@@ -122,48 +122,26 @@ Keep an MR to one concern, and separate mechanical changes such as renames from
122
122
 
123
123
  ## Rate limiting
124
124
 
125
- `Labkit::RateLimit` is documented in `lib/labkit/rate_limit/README.md`.
126
- It is the one capability that shares state across SDKs: services in any language enforce the same limits against the same Redis.
127
- That makes the Redis key shape and the Lua scripts as much a contract as the HTTP headers, so changes here follow stricter rules than the rest of the gem.
128
-
129
- ### Where each SDK keeps it
130
-
131
- | SDK | Repository | Module | Tests | Status |
132
- | --- | --- | --- | --- | --- |
133
- | Ruby (this repository) | gitlab-org/ruby/gems/labkit-ruby | `lib/labkit/rate_limit/` | `spec/labkit/rate_limit/` | released |
134
- | Go | gitlab-org/labkit | `v2/ratelimit/` | `v2/ratelimit/*_test.go`, run with `go -C v2 test ./ratelimit/...` | released |
135
- | Rust | gitlab-org/rust/labkit-rs | in progress | | not released, skip |
136
- | Python | gitlab-org/labkit-python | in progress | | not released, skip |
137
- | Spec | gitlab-org/quality/tooling/labkit-spec | `conformance/README.md` ("v1 decisions"), `conformance/ratelimit_scenarios/*.json`, `conformance/aspect_ratelimit.go` | `conformance/` | judge |
125
+ Services in every language enforce the same rate limits against one shared Redis.
126
+ So these are a contract between all SDKs:
138
127
 
139
- ### The three layers
128
+ - Redis keys, values, and TTLs
129
+ - Lua scripts
130
+ - the counting rule that decides a block
131
+ - the response status and `RateLimit-*` headers
132
+ - metrics and log fields
140
133
 
141
- Every SDK must agree on three layers.
142
- This section defines the layers; the current values (key grammar, header names, counting rules, metric names, log fields) live in labkit-spec, in the "v1 decisions" list in `conformance/README.md` and the scenarios under `conformance/ratelimit_scenarios/`.
134
+ [labkit-spec](https://gitlab.com/gitlab-org/quality/tooling/labkit-spec) owns that contract: the "v1 decisions" in `conformance/README.md` and the scenarios in `conformance/ratelimit_scenarios/`.
143
135
  If this file and labkit-spec disagree, labkit-spec wins.
136
+ `Labkit::RateLimit` is documented in `lib/labkit/rate_limit/README.md`.
137
+
138
+ | SDK | Repository | Rate limiter | Tests |
139
+ | --- | --- | --- | --- |
140
+ | Ruby | [labkit-ruby](https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby) | `lib/labkit/rate_limit/` | `bundle exec rspec spec/labkit/rate_limit` |
141
+ | Go | [labkit](https://gitlab.com/gitlab-org/labkit) | `v2/ratelimit/` | `go -C v2 test ./ratelimit/...` |
142
+ | Rust | [labkit-rs](https://gitlab.com/gitlab-org/rust/labkit-rs) | `labkit/src/ratelimit/` | `cargo test -p labkit --features ratelimit-redis --lib ratelimit` |
144
143
 
145
- 1. **Storage contract.**
146
- Anything written to or read from Redis: key shape and prefix, how values are encoded, TTL rules, and the Lua scripts.
147
- Two SDKs sharing one Redis with different storage rules keep two counters for one limit.
148
- Never change this layer in one SDK.
149
- It changes in labkit-spec first, then every SDK follows.
150
- 2. **Observable contract.**
151
- Anything a caller or an operator can see: response status and headers, the counting rule that decides a block, log line messages and field sets, metric families and labels.
152
- Also changes in labkit-spec first.
153
- 3. **Behaviour within the contract.**
154
- Input validation and edge cases the contract already implies, where one SDK does something the contract does not allow.
155
- These may be fixed SDK by SDK, and every SDK should end up the same.
156
-
157
- A change that mixes layers is governed by the strictest one: storage, then observable, then behaviour.
158
- When a change does not fit one layer clearly, say so in the MR instead of picking one.
159
-
160
- ### Rules for rate-limit changes
161
-
162
- - A change to layer 1 or 2 opens a labkit-spec MR first and waits for it to merge.
163
- - A change to layer 3 should be checked against the Go SDK.
164
- If Go has the same gap, say so in the MR so it can be ported.
165
- - When a review finds a divergence between SDKs, a conformance scenario in labkit-spec must follow in the same week, so the harness sees the gap next time.
166
- The author of the MR where the divergence was found owns this.
167
- Mention the sync agent on the review thread to have it draft the scenario MR; if it cannot, open a labkit-spec issue and link it from the thread.
168
- - Never edit Lua scripts, key construction, header names, metric names, or log field names to fix a Ruby-only bug.
169
- If a fix seems to need that, it is a contract change.
144
+ - Change the contract in labkit-spec first, then in every SDK. Merge the labkit-spec MR before any SDK MR. Never change the contract in one SDK only.
145
+ - A fix inside the contract can land in one SDK. Check the other SDKs, and say in the MR if they have the same gap.
146
+ - If you are not sure that a change is inside the contract, treat it as a contract change and say so in the MR.
147
+ - When review finds a gap between SDKs, add a labkit-spec scenario for it in the same week, or open a labkit-spec issue and link it from the thread. The author of the MR where the gap was found owns this.
data/CODEOWNERS CHANGED
@@ -1,4 +1,4 @@
1
1
  # CODEOWNERS is used to lookup assignees for
2
2
  # Renovate Bot dependency change Merge Requests.
3
3
  # https://docs.renovatebot.com/configuration-options/#assigneesfromcodeowners
4
- * @ayufan @hmerscher @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2
4
+ * @ayufan @hmerscher @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2 @lhollinda
@@ -177,6 +177,7 @@ A `Rule` is a `Data.define` value object with the following fields:
177
177
  | `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
178
178
  | `count_distinct` | Optional identifier key. When set, the rule counts distinct values seen for that key within the window, backed by a Redis SET rather than a counter. Must not overlap `characteristics`. |
179
179
  | `ban_for` | Optional ban duration, whole seconds, minimum 1. Changes the accounting; `action` still decides who is blocked. Rejected on `:skip`. May be a callable resolved on every check. |
180
+ | `admit_counted_values` | Optional, default `false`. Only valid with `count_distinct`, and rejected on `:skip`. When `true`, the rule counts only the values it admits, so values already counted keep passing once the set is full. Rename the rule when changing it. See [Admitting counted values](#admitting-counted-values). |
180
181
 
181
182
  Making `limit` or `period` callable is the supported pattern for
182
183
  runtime-tunable thresholds (e.g. feature flags or database-backed settings):
@@ -193,9 +194,11 @@ Labkit::RateLimit::Rule.new(
193
194
  `Rule.new` raises `ArgumentError` for a static `limit` under 1 or a static
194
195
  `period` under one second, except on `:skip` rules where both are inert.
195
196
  Callables are not validated: a `limit` resolving to `0` blocks every matching
196
- request, which is how to express "deny all" dynamically, and a `period`
197
- resolving under one second reaches `EXPIRE` as `0` and deletes the counter on
198
- every write.
197
+ request, which is how to express "deny all" dynamically. On an
198
+ `admit_counted_values` rule it only denies new values, since values already
199
+ counted in the window still pass; to block those too, put a counter rule whose
200
+ limit resolves to `0` in front of it. A `period` resolving under one second
201
+ reaches `EXPIRE` as `0` and deletes the counter on every write.
199
202
 
200
203
  ### Matchers
201
204
 
@@ -315,7 +318,7 @@ flowchart TD
315
318
  Eval --> Build[Build Evaluation<br/>resolve limit/period]
316
319
  Build --> Add[Add evaluation to Result]
317
320
  Add --> Emit[Emit rule_evaluations_total<br/>+ limit/period gauges]
318
- Emit --> Act{"result.block?<br/>(:limit rule over its limit,<br/>or its ban in force)"}
321
+ Emit --> Act{"result.block?<br/>(:limit rule over its limit,<br/>its ban in force, or an admitting<br/>rule that refused the value)"}
319
322
  Act -->|yes| Return([Return Result<br/>action=:block])
320
323
  Act -->|"no (:log, or under limit)"| Iter
321
324
  Iter -->|no more rules| Return2([Return Result reporting<br/>most-constraining evaluation,<br/>or matched=false if none])
@@ -349,10 +352,11 @@ visible via `checks_total{error="true"}` and the
349
352
 
350
353
  The rule's `action` describes what the rule does; the result's `action`
351
354
  describes the outcome — what the caller should do — and is only ever `:allow`
352
- or `:block`. A matching rule increments its counter, with two exceptions:
353
- `:skip` rules never touch Redis, and a rule carrying `ban_for` whose ban is
354
- already in force short-circuits before incrementing. The `result` label on
355
- `rule_evaluations_total` records what each evaluation decided:
355
+ or `:block`. A matching rule increments its counter, with three exceptions:
356
+ `:skip` rules never touch Redis, a rule carrying `ban_for` whose ban is
357
+ already in force short-circuits before incrementing, and an
358
+ `admit_counted_values` rule adds a value only when it admits it. The `result`
359
+ label on `rule_evaluations_total` records what each evaluation decided:
356
360
 
357
361
  | rule action | what it does | exceeded? | result action | `result` label | terminating? |
358
362
  |-------------|-----------------------------------------------|-----------|---------------|----------------|---------------|
@@ -419,6 +423,60 @@ window, and it stops counting while that ban holds:
419
423
  suit counting discrete failures, such as bad credentials, better than they
420
424
  suit variable-cost resource limits.
421
425
 
426
+ ### Admitting counted values
427
+
428
+ A plain `count_distinct` rule adds every checked value to its set, including
429
+ the values it blocks. Once one value is blocked, the count stays over the
430
+ limit for the rest of the window, so values that were counted earlier are
431
+ blocked too. That suits "block after N distinct values", such as the number
432
+ of users seen from one IP.
433
+
434
+ Set `admit_counted_values: true` to cap how many distinct values can be
435
+ admitted per window instead:
436
+
437
+ ```ruby
438
+ Labkit::RateLimit::Rule.new(
439
+ name: "participants_per_namespace",
440
+ limit: 20,
441
+ period: 86_400,
442
+ characteristics: [:namespace],
443
+ count_distinct: :email_digest,
444
+ admit_counted_values: true
445
+ )
446
+ ```
447
+
448
+ - A value already in the set is allowed and leaves the count unchanged, even
449
+ when the set is full.
450
+ - A new value is added only while the count is below the limit. Otherwise the
451
+ request is blocked and the value is not added, so a blocked value stays
452
+ blocked when it is retried and the count does not grow past the limit.
453
+ - A blocked request reports the set's count and `RateLimit-Remaining: 0`. The
454
+ block comes from the admission, not from `count > limit`, the same way a ban
455
+ decides the block apart from the count.
456
+ - If the limit is lowered during a window, the set can hold more values than
457
+ the new limit. Values already in it are still allowed, and new values are
458
+ blocked until the window ends.
459
+ - `peek` reports what `check` would decide when the identifier has the
460
+ `count_distinct` value, without writing: exceeded only when that value is
461
+ not counted and the count has reached the limit. Without the value, `check`
462
+ skips the rule, but `peek` answers for a value that is not counted yet, so
463
+ it reports exceeded once the count reaches the limit.
464
+ - A `:log` rule does the same accounting, including not adding refused
465
+ values, and only skips the blocking.
466
+ - `cost:` is ignored, as for a plain `count_distinct` rule, so a `cost: 0`
467
+ check still admits a new value.
468
+ - Rules count a request up to the one that blocks it. An admitting rule placed
469
+ before a counter rule that blocks still admits the value, which then takes a
470
+ slot for the rest of the window. Put admitting rules after the counter rules
471
+ in the same limiter.
472
+
473
+ The Redis key and the stored values are the same as for a plain
474
+ `count_distinct` rule, and a plain rule adds every value it checks, including
475
+ the ones it blocks. A rule switched between the two modes keeps those values,
476
+ and they count as admitted until the window ends. Rename the rule when
477
+ changing its mode, which abandons its counters (see [Rule](#rule)), and give a
478
+ rule the same mode in every SDK that writes to it.
479
+
422
480
  ### Redis keys
423
481
 
424
482
  Each matched check writes a key shaped:
@@ -439,7 +497,8 @@ is not extended on subsequent increments, so the window is a true fixed
439
497
  window starting at the first request, not a sliding window.
440
498
 
441
499
  A check is a single `EVALSHA` of `INCR_SCRIPT` (or `SADD_SCRIPT` for
442
- `count_distinct` rules, or `BAN_SCRIPT` for rules carrying `ban_for`). Doing the whole
500
+ `count_distinct` rules, `ADMIT_SCRIPT` for `count_distinct` rules with
501
+ `admit_counted_values`, or `BAN_SCRIPT` for rules carrying `ban_for`). Doing the whole
443
502
  read-modify-write inside Lua means there is no window between the increment
444
503
  and the `EXPIRE` in which a key could be left without a TTL.
445
504
 
@@ -488,9 +547,10 @@ key with no expiry, the TTL only needs reading once — after the increment.
488
547
  That single read distinguishes both cases the script must handle, so there is
489
548
  no reason to read it again before mutating.
490
549
 
491
- `peek` does not use a script: it pipelines `GET` + `TTL` (or `SCARD` + `TTL`)
492
- and never issues `EXPIRE`, so it cannot start or extend a window. A missing
493
- key (`GET → nil`, `TTL → -2`) is reported as `count = 0`, and `build_evaluation`
550
+ `peek` does not use a script: it pipelines `GET` + `TTL` (or `SCARD` + `TTL`,
551
+ plus `SISMEMBER` for an `admit_counted_values` rule when the identifier has the
552
+ value) and never issues `EXPIRE`, so it cannot start or extend a window. A
553
+ missing key (`GET → nil`, `TTL → -2`) is reported as `count = 0`, and `build_evaluation`
494
554
  falls back to the rule's period for `reset_at` since there is no Redis-side
495
555
  window to read.
496
556
 
@@ -503,7 +563,7 @@ the most constraining one (see [Evaluation flow](#evaluation-flow)):
503
563
  ```ruby
504
564
  result.matched? # => true if some rule matched
505
565
  result.skipped? # => true if a matched :skip rule bypassed the check
506
- result.exceeded? # => true if the reported rule's counter > limit
566
+ result.exceeded? # => true if the reported rule is over its limit
507
567
  result.action # => :block | :allow — what the caller should do
508
568
  result.block? # => result.action == :block
509
569
  result.rule # => the reported Rule, or nil
@@ -67,6 +67,40 @@ module Labkit
67
67
  return {count, ttl_after}
68
68
  LUA
69
69
 
70
+ # Atomic admission for count_distinct rules carrying admit_counted_values.
71
+ # A member already in the set is admitted without changing it; a new one
72
+ # is added only while SCARD is below the limit. A refused member is never
73
+ # added, so it stays refused on retry and the count does not grow past the
74
+ # limit. Returns post-state {count, TTL, admitted}.
75
+ #
76
+ # The same self-healing as SADD_SCRIPT applies to keys left without TTL.
77
+ # EXPIRE on a missing key does nothing, so a refusal on a missing key (a
78
+ # limit resolving to 0) writes nothing.
79
+ ADMIT_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
80
+ local ttl = ARGV[1]
81
+ local member = ARGV[2]
82
+ local limit = tonumber(ARGV[3])
83
+
84
+ local count = redis.call('SCARD', KEYS[1])
85
+ local admitted = 1
86
+ if redis.call('SISMEMBER', KEYS[1], member) == 0 then
87
+ if count < limit then
88
+ redis.call('SADD', KEYS[1], member)
89
+ count = count + 1
90
+ else
91
+ admitted = 0
92
+ end
93
+ end
94
+
95
+ local ttl_after = redis.call('TTL', KEYS[1])
96
+ if ttl_after < 0 then
97
+ redis.call('EXPIRE', KEYS[1], ttl)
98
+ ttl_after = tonumber(ttl)
99
+ end
100
+
101
+ return {count, ttl_after, admitted}
102
+ LUA
103
+
70
104
  # Atomic ban check + INCR + conditional ban write, for rules carrying
71
105
  # ban_for.
72
106
  #
@@ -262,9 +296,10 @@ module Labkit
262
296
  # here too: check can report a :log rule's evaluation, so excluding them
263
297
  # would make peek answer a different question than check.
264
298
  #
265
- # peek does not need the count_distinct identifier key - it reads SCARD on
266
- # the rule-keyed compound key, which contains the cardinality across all
267
- # members. So missing-key fail-open does not apply here.
299
+ # peek does not require the count_distinct identifier key: it reads SCARD
300
+ # on the rule-keyed compound key, so missing-key fail-open does not apply.
301
+ # An admitting rule also looks the value up when it is present (see
302
+ # peek_admission_rule).
268
303
  def peek_rules(identifier, rule_context, cursor)
269
304
  result = Result.new
270
305
 
@@ -302,6 +337,8 @@ module Labkit
302
337
  resolved_limit = Integer(resolve_value(rule.limit, rule_context))
303
338
  resolved_period = Integer(resolve_value(rule.period, rule_context))
304
339
 
340
+ return evaluate_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period) if rule.admit_counted_values
341
+
305
342
  # cost is ignored for count_distinct rules: SADD is binary (a member is
306
343
  # either added or not), and the post-add count is SCARD regardless.
307
344
  count, ttl =
@@ -314,6 +351,15 @@ module Labkit
314
351
  build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
315
352
  end
316
353
 
354
+ # The count does not grow past the limit in this mode, so the script's
355
+ # admission decides the block, the way a ban decides it apart from the count.
356
+ # cost is ignored, as for a plain count_distinct rule.
357
+ def evaluate_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period)
358
+ count, ttl, admitted = admit_with_ttl(redis_key, identifier[rule.count_distinct], resolved_limit, resolved_period)
359
+
360
+ build_evaluation(rule, resolved_limit, resolved_period, count, ttl, exceeded: !admitted)
361
+ end
362
+
317
363
  def peek_rule(rule, identifier, rule_context)
318
364
  return peek_ban_rule(rule, identifier, rule_context) if rule.ban_for
319
365
 
@@ -321,10 +367,22 @@ module Labkit
321
367
  resolved_limit = Integer(resolve_value(rule.limit, rule_context))
322
368
  resolved_period = Integer(resolve_value(rule.period, rule_context))
323
369
 
370
+ return peek_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period) if rule.admit_counted_values
371
+
324
372
  count, ttl = rule.count_distinct ? scard_with_ttl(redis_key) : read_with_ttl(redis_key)
325
373
  build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
326
374
  end
327
375
 
376
+ # An admission is decided before the script writes anything, so with the
377
+ # value peek reports exactly what check would. Without it, check skips the
378
+ # rule, but peek answers for a value that is not counted yet.
379
+ def peek_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period)
380
+ value = identifier[rule.count_distinct] unless missing_count_distinct_value?(rule, identifier)
381
+ count, ttl, counted = scard_with_ttl(redis_key, member: value)
382
+
383
+ build_evaluation(rule, resolved_limit, resolved_period, count, ttl, exceeded: !counted && count >= resolved_limit)
384
+ end
385
+
328
386
  def evaluate_ban_rule(rule, identifier, cost, rule_context)
329
387
  resolved_limit = Integer(resolve_value(rule.limit, rule_context))
330
388
  resolved_period = Integer(resolve_value(rule.period, rule_context))
@@ -382,7 +440,7 @@ module Labkit
382
440
  Result::Evaluation.new(rule: rule, exceeded: banned, info: info)
383
441
  end
384
442
 
385
- def build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
443
+ def build_evaluation(rule, resolved_limit, resolved_period, count, ttl, exceeded: count > resolved_limit)
386
444
  info = Result::Info.new(
387
445
  resolved_limit: resolved_limit, resolved_period: resolved_period,
388
446
  count: count,
@@ -390,12 +448,13 @@ module Labkit
390
448
  reset_at: Time.now.utc + (ttl >= 0 ? ttl : resolved_period)
391
449
  )
392
450
 
393
- Result::Evaluation.new(rule: rule, exceeded: count > resolved_limit, info: info)
451
+ Result::Evaluation.new(rule: rule, exceeded: exceeded, info: info)
394
452
  end
395
453
 
396
454
  # RateLimit-Remaining must not exceed RateLimit-Limit, and a counter can
397
455
  # sit below zero if an older client credited it. A limit resolving to a
398
- # non-positive value means "deny all", so the ceiling floors at zero too.
456
+ # non-positive value means "deny all" ("deny new values" on an
457
+ # admit_counted_values rule), so the ceiling floors at zero too.
399
458
  def remaining_of(resolved_limit, count)
400
459
  ceiling = [resolved_limit, 0].max
401
460
  (ceiling - count).clamp(0, ceiling)
@@ -523,17 +582,28 @@ module Labkit
523
582
  end
524
583
  end
525
584
 
526
- # Pipelined SCARD + TTL. SCARD on a missing key returns 0, so no
527
- # explicit nil handling is needed (unlike GET in read_with_ttl).
528
- # SCARD is integer-valued; coerced to Float for type uniformity with
529
- # the INCR path so callers see a consistent count type.
530
- def scard_with_ttl(redis_key)
585
+ # Atomic admission. See ADMIT_SCRIPT.
586
+ def admit_with_ttl(redis_key, member, limit, period)
587
+ member_str = encode_char_value(member.to_s)
588
+ @redis.with do |conn|
589
+ raw_count, ttl, admitted = ADMIT_SCRIPT.eval(conn, keys: [redis_key], argv: [period, member_str, limit])
590
+ [Float(raw_count), Integer(ttl), admitted == 1]
591
+ end
592
+ end
593
+
594
+ # Pipelined SCARD + TTL, plus SISMEMBER when given a member to look up
595
+ # (admitting rules), returned as the third element. SCARD on a missing key
596
+ # returns 0, so no explicit nil handling is needed (unlike GET in
597
+ # read_with_ttl). SCARD is integer-valued; coerced to Float for type
598
+ # uniformity with the INCR path so callers see a consistent count type.
599
+ def scard_with_ttl(redis_key, member: nil)
531
600
  @redis.with do |conn|
532
- scard, ttl = conn.pipelined do |pipe|
601
+ scard, ttl, counted = conn.pipelined do |pipe|
533
602
  pipe.scard(redis_key)
534
603
  pipe.ttl(redis_key)
604
+ pipe.sismember(redis_key, encode_char_value(member.to_s)) unless member.nil?
535
605
  end
536
- [Float(scard), ttl]
606
+ [Float(scard), ttl, counted == true]
537
607
  end
538
608
  end
539
609
 
@@ -35,6 +35,8 @@ module Labkit
35
35
  # @param cost [Numeric] amount to add to the counter. Defaults to 1
36
36
  # (count-mode). Pass a non-1 Numeric for cost-mode counters such as
37
37
  # resource-usage limits; passing 0 reads the counter without writing.
38
+ # count_distinct rules ignore cost, so a cost of 0 still adds the value
39
+ # (or admits it, on an admit_counted_values rule).
38
40
  # A negative cost would credit quota back, so the check fails open and
39
41
  # logs instead of counting.
40
42
  # @param rule_context [Hash, nil] optional per-request context passed to
@@ -60,8 +62,10 @@ module Labkit
60
62
  #
61
63
  # When the underlying Redis key does not exist yet, the result reports
62
64
  # count=0, exceeded=false, and remaining=resolved_limit; matched? is
63
- # still true because the rule applied. On Redis error the result fails
64
- # open identically to {#check}.
65
+ # still true because the rule applied. The exception is an
66
+ # admit_counted_values rule whose limit resolves to 0 or less, which
67
+ # refuses every new value and so reports exceeded. On Redis error the result
68
+ # fails open identically to {#check}.
65
69
  #
66
70
  # @param identifier [Identifier, Hash] caller attributes for this request
67
71
  # @param rule_context [Hash, nil] see {#check}
@@ -15,7 +15,8 @@ module Labkit
15
15
  # from the same evaluations are not #==.
16
16
  #
17
17
  # matched? - true if at least one rule's match conditions were satisfied
18
- # exceeded? - true if the reported evaluation's counter exceeded its limit
18
+ # exceeded? - true if the reported evaluation is over its limit (see
19
+ # Result::Evaluation#exceeded)
19
20
  # action - the outcome: what the caller should do
20
21
  # :block = a :limit rule matched and is over its limit
21
22
  # :allow = everything else, including an exceeded :log rule
@@ -127,7 +128,8 @@ module Labkit
127
128
 
128
129
  # The outcome of counting one matched rule.
129
130
  # rule - the evaluated Rule
130
- # exceeded - whether the post-increment count exceeded the resolved limit
131
+ # exceeded - whether the rule is over its limit: the post-increment count
132
+ # exceeded it, a ban holds, or an admitting rule refused the value
131
133
  # info - Result::Info with the per-window counters
132
134
  #
133
135
  # Evaluations order by constraint: a blocking evaluation (a :limit rule over
@@ -165,7 +167,8 @@ module Labkit
165
167
  headers = {}
166
168
  headers["RateLimit-Name"] = rule.name
167
169
  # Floored at 0 like remaining: a callable limit can resolve below zero
168
- # to deny all, and an older client could credit a counter below zero.
170
+ # to deny all (deny new values on an admit_counted_values rule), and an
171
+ # older client could credit a counter below zero.
169
172
  headers["RateLimit-Limit"] = [info.resolved_limit.to_i, 0].max.to_s
170
173
  headers["RateLimit-Observed"] = [info.count.to_i, 0].max.to_s
171
174
  headers["RateLimit-Remaining"] = info.remaining.to_i.to_s
@@ -31,11 +31,22 @@ module Labkit
31
31
  # the (characteristics-bucketed) period, backed by a Redis SET.
32
32
  # When nil (default), the rule counts the number of calls,
33
33
  # backed by INCR. The named key must not overlap +characteristics+.
34
+ # admit_counted_values - optional Boolean, only valid with +count_distinct+ and
35
+ # rejected on :skip.
36
+ # When true, the rule counts only the values it admits: a value
37
+ # already in the window's set is allowed even when the set is
38
+ # full, and a new value is added only while the count is below
39
+ # the limit, so a value this rule refuses is never counted.
40
+ # Rules count a request up to the one that blocks it, so put
41
+ # admitting rules after counter rules. Default false; nil
42
+ # means not set.
34
43
  #
35
44
  # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
36
45
  # characters. It is used as the middle segment of every Redis counter key for
37
46
  # this rule, so changing a rule's name mid-window abandons its in-flight counters.
38
- Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics, :count_distinct, :ban_for) do
47
+ Rule = Data.define(
48
+ :name, :match, :limit, :period, :action, :characteristics, :count_distinct, :ban_for, :admit_counted_values
49
+ ) do
39
50
  # ban_for changes the accounting; action still decides who gets blocked.
40
51
  # A :skip rule counts nothing, so it has no limit to cross and no ban to
41
52
  # write.
@@ -58,7 +69,8 @@ module Labkit
58
69
 
59
70
  # A static limit under 1 blocks every request, and a period under a second
60
71
  # reaches EXPIRE as 0, deleting the counter on every write. A :skip rule
61
- # counts nothing, and a callable may still resolve to 0 to mean "deny all".
72
+ # counts nothing, and a callable may still resolve to 0 to mean "deny all"
73
+ # ("deny new values" on an admit_counted_values rule).
62
74
  def self.normalize_numeric(field, value, action_sym)
63
75
  return value if action_sym == :skip || value.respond_to?(:call)
64
76
  return value if value.is_a?(Numeric) && value >= 1
@@ -66,6 +78,19 @@ module Labkit
66
78
  raise ArgumentError, "#{field} must be a Numeric of at least 1 or a callable, got #{value.inspect}"
67
79
  end
68
80
 
81
+ # Admission only means something for a SET-backed rule, where a value can
82
+ # already be counted. A counter has no members to admit, and a :skip rule
83
+ # counts nothing.
84
+ def self.normalize_admit_counted_values(value, action_sym, count_distinct_sym)
85
+ # nil means "not set", like count_distinct and ban_for.
86
+ value = false if value.nil?
87
+ raise ArgumentError, "admit_counted_values must be true, false, or nil, got #{value.inspect}" unless [true, false].include?(value)
88
+ raise ArgumentError, "admit_counted_values is not valid on a :skip rule" if value && action_sym == :skip
89
+ raise ArgumentError, "admit_counted_values requires count_distinct" if value && count_distinct_sym.nil?
90
+
91
+ value
92
+ end
93
+
69
94
  def self.normalize_count_distinct(value, characteristics_arr)
70
95
  sym =
71
96
  case value
@@ -81,9 +106,9 @@ module Labkit
81
106
  sym
82
107
  end
83
108
 
84
- def initialize(
109
+ def initialize( # rubocop:disable Metrics/ParameterLists -- one keyword per Rule field; the public API grows by keywords with defaults
85
110
  name:, limit:, period:, characteristics:,
86
- match: {}, action: :limit, count_distinct: nil, ban_for: nil
111
+ match: {}, action: :limit, count_distinct: nil, ban_for: nil, admit_counted_values: false
87
112
  )
88
113
  raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)
89
114
 
@@ -109,7 +134,8 @@ module Labkit
109
134
  action: action_sym,
110
135
  characteristics: characteristics_arr,
111
136
  count_distinct: count_distinct_sym,
112
- ban_for: self.class.normalize_ban_for(ban_for, action_sym, count_distinct_sym)
137
+ ban_for: self.class.normalize_ban_for(ban_for, action_sym, count_distinct_sym),
138
+ admit_counted_values: self.class.normalize_admit_counted_values(admit_counted_values, action_sym, count_distinct_sym)
113
139
  )
114
140
  end
115
141
  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: 5.8.0
4
+ version: 5.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -570,7 +570,6 @@ files:
570
570
  - ".gitlab-ci-asdf-versions.yml"
571
571
  - ".gitlab-ci-other-versions.yml"
572
572
  - ".gitlab-ci.yml"
573
- - ".gitlab/CODEOWNERS"
574
573
  - ".gitlab/duo/agent-config.yml"
575
574
  - ".gitlab/merge_request_templates/default.md"
576
575
  - ".gitleaks.toml"
data/.gitlab/CODEOWNERS DELETED
@@ -1 +0,0 @@
1
- * @mkaeppler @ayufan @hmerscher @sankalp_gl @hardikgala @nindurkar @ashs2