graphql-armory 0.1.0.pre4 → 0.1.0.pre5

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.
@@ -40,20 +40,66 @@ module GraphQL
40
40
  # `store:` answers `fetch(id)` with an operation (`body` and `document`) or nil: a Manifest, or your own.
41
41
  # `strict: true` refuses raw queries unless the context holds `armory_allow_raw_query: true`.
42
42
  def self.use(schema, store:, strict: false)
43
- if store.is_a?(Hash) || !store.respond_to?(:fetch)
44
- raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
45
- end
46
- raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
43
+ gate = Gate.build(store: store, strict: strict)
47
44
 
48
45
  if schema.singleton_class.include?(Guard)
49
46
  raise ConfigurationError, "#{schema} already uses PersistedQueries (here or in a parent schema): use it once"
50
47
  end
51
48
 
52
- schema.instance_variable_set(:@armory_persisted_queries, [store, strict].freeze)
49
+ schema.instance_variable_set(:@armory_persisted_queries, gate)
53
50
  schema.singleton_class.prepend(Guard)
54
51
  schema.query_class(schema.query_class)
55
52
  end
56
53
 
54
+ # Decides what a request runs: answers `[refusal, document]`, where a refusal is the error message, a
55
+ # document is the stored operation to run instead of the request's text, and `[nil, nil]` runs the request
56
+ # as it came.
57
+ class Gate
58
+ # Wires the app's store with `use`'s defaults; `use` goes through here.
59
+ def self.build(store:, strict: false)
60
+ new(store: store, strict: strict)
61
+ end
62
+
63
+ # `new` is inert: with no store wired it refuses every request. It checks what it is given, so it never
64
+ # lets raw queries through without a store either.
65
+ def initialize(store: Store::Null.new, strict: true)
66
+ if store.is_a?(Hash) || !store.respond_to?(:fetch)
67
+ raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
68
+ end
69
+ raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
70
+ raise ConfigurationError, "strict: false needs a store: with the Null store only raw queries would run" if !strict && store.is_a?(Store::Null)
71
+
72
+ @store = store
73
+ @strict = strict
74
+ end
75
+
76
+ def call(context, text, document)
77
+ operation_id = context[:operation_id] if context.respond_to?(:[])
78
+
79
+ if !operation_id.nil?
80
+ operation = operation_id.is_a?(String) ? @store.fetch(operation_id) : nil
81
+ if operation.nil?
82
+ [UNKNOWN_OPERATION, nil]
83
+ elsif document || (text && !same_query?(text, operation))
84
+ [MISMATCH, nil]
85
+ else
86
+ [nil, operation.document]
87
+ end
88
+ elsif @strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
89
+ [RAW_QUERY_REFUSED, nil]
90
+ else
91
+ [nil, nil]
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Apollo's clients may send the query with its id; graphql-ruby's subscriptions resend it printed.
98
+ def same_query?(text, operation)
99
+ text == operation.body || text == operation.document.to_query_string
100
+ end
101
+ end
102
+
57
103
  # Keeps the lookup in every query class the schema uses, including one set after `use` or by a child schema.
58
104
  module Guard
59
105
  UNSET = Object.new.freeze
@@ -101,25 +147,9 @@ module GraphQL
101
147
  # Swaps the request's query for the stored document, or for nothing with an error when it is refused.
102
148
  module Lookup
103
149
  def initialize(schema, query_string = nil, **options)
104
- store, strict = schema.armory_persisted_queries
105
- context = options[:context]
106
- operation_id = context[:operation_id] if context.respond_to?(:[])
107
150
  text = query_string || options[:query]
108
151
  text = nil if text == ""
109
-
110
- refusal, document =
111
- if !operation_id.nil?
112
- operation = operation_id.is_a?(String) ? store.fetch(operation_id) : nil
113
- if operation.nil?
114
- UNKNOWN_OPERATION
115
- elsif options[:document] || (text && !same_query?(text, operation))
116
- MISMATCH
117
- else
118
- [nil, operation.document]
119
- end
120
- elsif strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
121
- RAW_QUERY_REFUSED
122
- end
152
+ refusal, document = schema.armory_persisted_queries.(options[:context], text, options[:document])
123
153
 
124
154
  return super if refusal.nil? && document.nil?
125
155
  return super(schema, nil, **options, query: nil, document: document) if refusal.nil?
@@ -129,16 +159,11 @@ module GraphQL
129
159
  # with its own validator.
130
160
  super(schema, nil, **options, query: nil, document: options[:document] || EMPTY_DOCUMENT, validate: true, static_validator: Refusal.new(schema, refusal))
131
161
  end
132
-
133
- private
134
-
135
- # Apollo's clients may send the query with its id; graphql-ruby's subscriptions resend it printed.
136
- def same_query?(text, operation)
137
- text == operation.body || text == operation.document.to_query_string
138
- end
139
162
  end
140
163
  end
141
164
  end
142
165
  end
143
166
 
144
167
  require "graphql/armory/persisted_queries/manifest"
168
+ require "graphql/armory/persisted_queries/store"
169
+ require "graphql/armory/persisted_queries/controls"
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ module RateLimits
8
+ # Lets each client run at most `limit` operations at the same time:
9
+ #
10
+ # use GraphQL::Armory::RateLimits::Concurrency, store: GraphQL::Armory::RateLimits::MemoryStore.new,
11
+ # limit: 5, soft: false
12
+ #
13
+ # A query analyzer takes a slot before the operation runs, or refuses it before any field resolves; a trace
14
+ # releases every slot its multiplex took when the multiplex ends, even when it raises. A slot a crashed
15
+ # process never released expires after `stale_after` seconds. Soft mode (the default) records but refuses
16
+ # nothing. What happened is in `context[:rate_limit_concurrency]`.
17
+ class Concurrency
18
+ CONTEXT_KEY = :rate_limit_concurrency
19
+ NAMESPACE = :graphql_armory
20
+
21
+ def self.use(schema, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
22
+ concurrency = build(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
23
+ schema.query_analyzer(Class.new(Analyzer) { define_method(:concurrency) { concurrency } })
24
+ schema.trace_with(Release)
25
+ end
26
+
27
+ # Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
28
+ def self.build(store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
29
+ new(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
30
+ end
31
+
32
+ # `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
33
+ # configuration lets a query through either.
34
+ def initialize(store: Store::Null.new, limit: 1, stale_after: 60, soft: false, key: nil, on_store_error: nil)
35
+ unless Settings.store_answers?(store, acquire: %i[token limit ttl force], release: %i[token])
36
+ raise ConfigurationError, "store: must answer acquire(key, token:, limit:, ttl:, force:) and release(key, token:), like a MemoryStore"
37
+ end
38
+
39
+ @store = store
40
+ @limit = Settings.wired_limit(limit, store)
41
+ @stale_after = Settings.seconds(:stale_after, stale_after)
42
+ @soft = Settings.wired_soft(soft, store)
43
+ @key = Settings.key_reader(key)
44
+ @on_store_error = Settings.store_error_handler(on_store_error)
45
+ end
46
+
47
+ # Takes a slot for the query: the refusal when none is free, or nil to let it run.
48
+ def call(query)
49
+ unless query.current_trace.is_a?(Release)
50
+ raise ConfigurationError, "this query runs with its own trace (context[:trace]), so Concurrency could never release its slot: remove context[:trace] or add trace_with to that trace"
51
+ end
52
+
53
+ key = Settings.key_for(@key, query)
54
+ limit = Settings.limit_for(@limit, key, query)
55
+ if limit.nil?
56
+ record(query, key: key, limit: nil, active: nil, limited: false)
57
+ return nil
58
+ end
59
+
60
+ token = SecureRandom.hex(16)
61
+ answer = begin
62
+ @store.acquire(key, token: token, limit: limit, ttl: @stale_after, force: @soft)
63
+ rescue StandardError => error
64
+ @on_store_error.call(error, query)
65
+ return nil
66
+ end
67
+ acquired, active = Settings.checked(answer, :acquire, "the Integer slots held")
68
+
69
+ # Per query, not in the context hash: queries of a multiplex may share one hash.
70
+ query.context.namespace(NAMESPACE)[:concurrency_slot] = [self, key, token] if acquired || @soft
71
+ record(query, key: key, limit: limit, active: active, limited: !acquired)
72
+ refusal(limit, active) unless acquired || @soft
73
+ end
74
+
75
+ def release(query, key, token)
76
+ @store.release(key, token: token)
77
+ rescue StandardError => error
78
+ @on_store_error.call(error, query)
79
+ end
80
+
81
+ private
82
+
83
+ def record(query, **info)
84
+ query.context[CONTEXT_KEY] = { **info.slice(:key, :limit, :active), soft: @soft, limited: info[:limited] }
85
+ end
86
+
87
+ def refusal(limit, active)
88
+ GraphQL::AnalysisError.new(
89
+ "Too many operations at the same time: #{active} are already running for this client (limit #{limit}). Retry when one finishes.",
90
+ extensions: { "code" => "RATE_LIMITED", "limit" => limit, "active" => active }
91
+ )
92
+ end
93
+
94
+ # Takes the slot during analysis, before anything runs.
95
+ class Analyzer < GraphQL::Analysis::Analyzer
96
+ def result
97
+ concurrency.(query)
98
+ end
99
+ end
100
+
101
+ # Releases the slots of every query in the multiplex once it is done, whatever happened. Analysis runs
102
+ # inside `execute_multiplex` on both engines, so every slot taken is released here.
103
+ module Release
104
+ def execute_multiplex(multiplex:)
105
+ super
106
+ ensure
107
+ # Every slot is released even if one release raises (an on_store_error that raises); the first such
108
+ # error is raised afterwards.
109
+ failure = nil
110
+ multiplex.queries.each do |query|
111
+ slots = query.context.namespace(NAMESPACE)
112
+ concurrency, key, token = slots.delete(:concurrency_slot)
113
+ next unless concurrency
114
+
115
+ begin
116
+ concurrency.release(query, key, token)
117
+ rescue StandardError => error
118
+ failure ||= error
119
+ end
120
+ end
121
+ raise failure if failure
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module RateLimits
6
+ # Test data for the rate limiters, reusable by apps and other gems.
7
+ module Controls
8
+ # A Query type: `cheap` costs 1 point, `report` costs 40, `broken` raises when resolved.
9
+ module Query
10
+ def self.example
11
+ Class.new(GraphQL::Schema::Object) do
12
+ graphql_name "Query"
13
+ field :cheap, String, null: false
14
+ field :report, String, null: false, complexity: 40
15
+ field :broken, String, null: false
16
+ end
17
+ end
18
+ end
19
+
20
+ # The root object for Query.example; it adds the name of each field it resolves to `resolved`.
21
+ module Root
22
+ def self.example(resolved = [])
23
+ Class.new do
24
+ define_method(:cheap) { resolved << :cheap; "ok" }
25
+ define_method(:report) { resolved << :report; "big" }
26
+ define_method(:broken) { raise IOError, "disk on fire" }
27
+ end.new
28
+ end
29
+ end
30
+
31
+ # A query context naming its client.
32
+ module Context
33
+ def self.example(key: "user:1", **context)
34
+ { RateLimits::KEY => key, **context }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module RateLimits
6
+ # Buckets kept in this process's memory: for tests, development and single-process servers. Several
7
+ # processes each keep their own buckets, so a client gets the limit once per process; share a store between
8
+ # processes in production.
9
+ class MemoryStore
10
+ def self.monotonic_clock
11
+ -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
12
+ end
13
+
14
+ def initialize(clock: self.class.monotonic_clock)
15
+ @clock = clock
16
+ @buckets = {}
17
+ @slots = {}
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # Token bucket: holds up to `limit` points and refills `limit` points every `window` seconds, continuously.
22
+ # Takes `cost` points when they are there; with `force: true` takes them anyway, down to zero. Answers
23
+ # whether they were there and the whole points left.
24
+ def take(key, cost:, limit:, window:, force: false)
25
+ @mutex.synchronize do
26
+ now = @clock.call
27
+ level, at = @buckets.fetch(key) { [limit, now] }
28
+ level = [level + ([now - at, 0].max * limit / window.to_f), limit].min
29
+
30
+ taken = cost <= level
31
+ level = [level - cost, 0].max if taken || force
32
+ @buckets[key] = [level, [now, at].max]
33
+
34
+ [taken, level.floor]
35
+ end
36
+ end
37
+
38
+ # Slots for operations running at the same time: at most `limit` per key, each held by a token until
39
+ # released or until `ttl` seconds pass (a process that crashed never releases). Takes a slot when one is
40
+ # free; with `force: true` takes it anyway. Answers whether one was free and the slots now held.
41
+ def acquire(key, token:, limit:, ttl:, force: false)
42
+ @mutex.synchronize do
43
+ now = @clock.call
44
+ slots = (@slots[key] ||= {})
45
+ slots.delete_if { |_token, expires_at| expires_at <= now }
46
+
47
+ acquired = slots.size < limit
48
+ slots[token] = now + ttl if acquired || force
49
+
50
+ [acquired, slots.size]
51
+ end
52
+ end
53
+
54
+ def release(key, token:)
55
+ @mutex.synchronize do
56
+ slots = @slots[key] or return
57
+ slots.delete(token)
58
+ @slots.delete(key) if slots.empty?
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module RateLimits
6
+ # Charges each query its complexity, as graphql-ruby computes it before running it, against a bucket of
7
+ # points per client that refills over a window:
8
+ #
9
+ # use GraphQL::Armory::RateLimits::Points, store: GraphQL::Armory::RateLimits::MemoryStore.new,
10
+ # limit: 1_000, window: 60, soft: false
11
+ #
12
+ # A query that costs more than the client has left is refused before any field resolves, and charged
13
+ # nothing. Soft mode (the default) charges and records but refuses nothing. What happened is in
14
+ # `context[:rate_limit_points]`.
15
+ class Points
16
+ CONTEXT_KEY = :rate_limit_points
17
+ NAMESPACE = :graphql_armory
18
+
19
+ def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
20
+ points = build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
21
+ schema.query_analyzer(Class.new(Analyzer) { define_method(:points) { points } })
22
+ end
23
+
24
+ # HTTP headers for the budget a result recorded: `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`
25
+ # (seconds until the bucket is full again). Empty when the query had no limit.
26
+ def self.headers(result)
27
+ info = result.context.namespace(NAMESPACE)[:points]
28
+ return {} unless info && info[:limit]
29
+
30
+ { "RateLimit-Limit" => info[:limit].to_s, "RateLimit-Remaining" => info[:remaining].to_s, "RateLimit-Reset" => reset_in(info).to_s }
31
+ end
32
+
33
+ def self.reset_in(info)
34
+ ((info[:limit] - info[:remaining]) * info[:window] / info[:limit].to_f).ceil
35
+ end
36
+
37
+ # Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
38
+ def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
39
+ new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
40
+ end
41
+
42
+ # `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
43
+ # configuration lets a query through either.
44
+ def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, extensions: false)
45
+ unless Settings.store_answers?(store, take: %i[cost limit window force])
46
+ raise ConfigurationError, "store: must answer take(key, cost:, limit:, window:, force:), like a MemoryStore"
47
+ end
48
+ raise ConfigurationError, "extensions: must be true or false, got #{extensions.inspect}" unless extensions == true || extensions == false
49
+
50
+ @store = store
51
+ @limit = Settings.wired_limit(limit, store)
52
+ @window = Settings.seconds(:window, window)
53
+ @soft = Settings.wired_soft(soft, store)
54
+ @key = Settings.key_reader(key)
55
+ @on_store_error = Settings.store_error_handler(on_store_error)
56
+ @extensions = extensions
57
+ end
58
+
59
+ # The refusal for a query that costs `cost`, or nil to let it run.
60
+ def call(query, cost)
61
+ key = Settings.key_for(@key, query)
62
+ limit = Settings.limit_for(@limit, key, query)
63
+ if limit.nil?
64
+ record(query, key: key, cost: cost, limit: nil, remaining: nil, limited: false)
65
+ return nil
66
+ end
67
+
68
+ answer = begin
69
+ @store.take(key, cost: cost, limit: limit, window: @window, force: @soft)
70
+ rescue StandardError => error
71
+ # No record: a context hash shared with an earlier query must not keep its numbers.
72
+ query.context[CONTEXT_KEY] = nil
73
+ @on_store_error.call(error, query)
74
+ return nil
75
+ end
76
+ taken, remaining = Settings.checked(answer, :take, "the Integer points left")
77
+
78
+ info = record(query, key: key, cost: cost, limit: limit, remaining: remaining, limited: !taken)
79
+ if @extensions
80
+ query.context.response_extensions["rateLimit"] =
81
+ { "cost" => cost, "limit" => limit, "remaining" => remaining, "resetIn" => self.class.reset_in(info) }
82
+ end
83
+ refusal(cost, limit, remaining) unless taken || @soft
84
+ end
85
+
86
+ private
87
+
88
+ # In the context hash for instrumentation, and per query for `headers`: queries of a multiplex may share one
89
+ # context hash, where the last query's record wins.
90
+ def record(query, **info)
91
+ info = { **info.slice(:key, :cost, :limit, :remaining), window: @window, soft: @soft, limited: info[:limited] }
92
+ query.context.namespace(NAMESPACE)[:points] = info
93
+ query.context[CONTEXT_KEY] = info
94
+ end
95
+
96
+ def refusal(cost, limit, remaining)
97
+ if cost > limit
98
+ return GraphQL::AnalysisError.new(
99
+ "Rate limit exceeded: this query costs #{cost} points and the limit is #{limit} every #{@window} seconds, so it can never run.",
100
+ extensions: { "code" => "RATE_LIMITED", "cost" => cost, "limit" => limit }
101
+ )
102
+ end
103
+
104
+ retry_after = ((cost - remaining) * @window / limit.to_f).ceil
105
+ GraphQL::AnalysisError.new(
106
+ "Rate limit exceeded: this query costs #{cost} points and #{remaining} remain (#{limit} every #{@window} seconds). Retry in #{retry_after} seconds.",
107
+ extensions: { "code" => "RATE_LIMITED", "cost" => cost, "remaining" => remaining, "retryAfter" => retry_after }
108
+ )
109
+ end
110
+
111
+ # graphql-ruby's own complexity analysis, then the charge.
112
+ class Analyzer < GraphQL::Analysis::QueryComplexity
113
+ def result
114
+ cost = super
115
+ unless cost.is_a?(Numeric) && cost.finite? && !cost.negative?
116
+ raise ConfigurationError, "the query complexity is #{cost.inspect}: field complexities must be numbers of zero or more"
117
+ end
118
+
119
+ # Fractional complexities (`complexity: 0.5`) are charged whole points, rounded up.
120
+ points.(query, cost.ceil)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ module RateLimits
8
+ # Buckets and slots kept in Redis, shared by every process of the app: the store for production.
9
+ #
10
+ # store = GraphQL::Armory::RateLimits::RedisStore.new(redis: Redis.new(url: ENV.fetch("REDIS_URL")))
11
+ #
12
+ # Each change is one Lua script, so concurrent requests never race, and the time comes from Redis's own clock,
13
+ # so processes on different machines agree on it. Keys live under `prefix` and expire once they no longer
14
+ # matter. `redis:` is a redis-rb client (>= 4.8), or anything answering `eval` and `evalsha` the same way;
15
+ # its errors reach the limiters, which let the query through and call `on_store_error`. This gem does not
16
+ # depend on the redis gem: the app adds it.
17
+ class RedisStore
18
+ DEFAULT_PREFIX = "graphql-armory:rate-limits:"
19
+
20
+ # The time in seconds: the one given by the caller (tests), or Redis's own clock.
21
+ CLOCK = <<~LUA
22
+ local function clock(given)
23
+ if given ~= "" then return tonumber(given) end
24
+ local time = redis.call("TIME")
25
+ return tonumber(time[1]) + tonumber(time[2]) / 1000000
26
+ end
27
+ LUA
28
+
29
+ # Same token bucket as MemoryStore#take. The bucket expires once a whole window has passed: it is full
30
+ # again by then, the same as no bucket at all. An expiry is only ever pushed later, never brought closer, so
31
+ # a limiter with a shorter window sharing the key cannot cut it short. The points left go back as a string:
32
+ # Redis would truncate a Lua number past 2**63. A bucket whose fields are not numbers is an error, so the
33
+ # limiter calls on_store_error instead of reading it as full.
34
+ TAKE = <<~LUA
35
+ #{CLOCK}
36
+ local cost, limit, window = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
37
+ local force, now = ARGV[4] == "1", clock(ARGV[5])
38
+ local state = redis.call("HMGET", KEYS[1], "level", "at")
39
+ local level, at = tonumber(state[1]), tonumber(state[2])
40
+ if not (state[1] or state[2]) then
41
+ level, at = limit, now
42
+ elseif not (level and at) or level ~= level or at ~= at or level < 0 or level == math.huge or math.abs(at) == math.huge then
43
+ return redis.error_reply("graphql-armory: corrupt rate limit bucket at " .. KEYS[1])
44
+ end
45
+ level = math.min(level + math.max(now - at, 0) * limit / window, limit)
46
+
47
+ local taken = cost <= level
48
+ if taken or force then level = math.max(level - cost, 0) end
49
+ redis.call("HSET", KEYS[1], "level", string.format("%.17g", level), "at", string.format("%.17g", math.max(now, at)))
50
+ local keep = math.ceil(window * 1000) + 1000
51
+ if redis.call("PTTL", KEYS[1]) < keep then redis.call("PEXPIRE", KEYS[1], keep) end
52
+
53
+ return { taken and 1 or 0, string.format("%.0f", math.floor(level)) }
54
+ LUA
55
+
56
+ # Same slots as MemoryStore#acquire: a sorted set of tokens scored by when they expire. The key lives as long
57
+ # as its latest slot, whatever ttl each acquire passed.
58
+ ACQUIRE = <<~LUA
59
+ #{CLOCK}
60
+ local token, limit, ttl = ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3])
61
+ local force, now = ARGV[4] == "1", clock(ARGV[5])
62
+ redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", string.format("%.17g", now))
63
+
64
+ local acquired = redis.call("ZCARD", KEYS[1]) < limit
65
+ if acquired or force then
66
+ redis.call("ZADD", KEYS[1], string.format("%.17g", now + ttl), token)
67
+ local latest = tonumber(redis.call("ZRANGE", KEYS[1], -1, -1, "WITHSCORES")[2])
68
+ redis.call("PEXPIRE", KEYS[1], math.ceil((latest - now) * 1000) + 1000)
69
+ end
70
+
71
+ return { acquired and 1 or 0, redis.call("ZCARD", KEYS[1]) }
72
+ LUA
73
+
74
+ RELEASE = <<~LUA
75
+ return redis.call("ZREM", KEYS[1], ARGV[1])
76
+ LUA
77
+
78
+ def initialize(redis:, prefix: DEFAULT_PREFIX, clock: nil)
79
+ unless redis.respond_to?(:eval) && redis.respond_to?(:evalsha)
80
+ raise ConfigurationError, "redis: must be a Redis client that runs scripts (eval and evalsha), like Redis.new from the redis gem"
81
+ end
82
+ raise ConfigurationError, "prefix: must be a non-empty String, got #{prefix.inspect}" unless prefix.is_a?(String) && !prefix.empty?
83
+ raise ConfigurationError, "clock: must be nil (Redis's clock) or answer call with the time in seconds" unless clock.nil? || clock.respond_to?(:call)
84
+
85
+ @redis = redis
86
+ @prefix = prefix
87
+ @clock = clock
88
+ end
89
+
90
+ def take(key, cost:, limit:, window:, force: false)
91
+ taken, level = script(TAKE, "#{@prefix}points:#{key}", cost, limit, seconds(window), flag(force), now)
92
+ [taken == 1, Integer(level, 10)]
93
+ end
94
+
95
+ def acquire(key, token:, limit:, ttl:, force: false)
96
+ acquired, held = script(ACQUIRE, "#{@prefix}slots:#{key}", token, limit, seconds(ttl), flag(force), now)
97
+ [acquired == 1, held]
98
+ end
99
+
100
+ def release(key, token:)
101
+ script(RELEASE, "#{@prefix}slots:#{key}", token)
102
+ nil
103
+ end
104
+
105
+ private
106
+
107
+ def flag(value)
108
+ value ? "1" : "0"
109
+ end
110
+
111
+ # Lua reads decimal numbers only: not "1/2" (a Rational) nor a Time.
112
+ def seconds(value)
113
+ Float(value).to_s
114
+ end
115
+
116
+ def now
117
+ @clock ? seconds(@clock.call.to_f) : ""
118
+ end
119
+
120
+ # EVALSHA sends only the script's digest; Redis forgets scripts on restart or SCRIPT FLUSH, and then EVAL
121
+ # sends it whole once more.
122
+ def script(source, key, *argv)
123
+ argv = argv.map(&:to_s)
124
+ @redis.evalsha(Digest::SHA1.hexdigest(source), keys: [key], argv: argv)
125
+ rescue StandardError => error
126
+ raise unless error.message.start_with?("NOSCRIPT")
127
+
128
+ @redis.eval(source, keys: [key], argv: argv)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end