Module: Wurk::Web::Enterprise::Limits

Defined in:
lib/wurk/web/enterprise.rb

Overview

Limits tab — list every registered limiter, filter by name, expose reset + delete. List/metrics already render via Wurk::Limiter::Base; this wrapper adds the name filter documented in §1.2.0+.

Constant Summary collapse

SWEEP_BATCH =

Names per sweep round trip. Bounds both halves: the probe's pipeline (whose replies the client buffers) and the removal script, which is atomic — an unbatched pass over a set that leaked for months would block every other Redis client for its whole length. A healthy set fits in one slice, so the common case still costs one probe.

512
PROBE_INTERVAL =

Liveness probes per process, at most one per this many seconds.

60.0

Class Method Summary collapse

Class Method Details

.filtered(names, filter) ⇒ Object



112
113
114
115
116
117
# File 'lib/wurk/web/enterprise.rb', line 112

def filtered(names, filter)
  return names if filter.nil? || filter.to_s.empty?

  needle = filter.to_s.downcase
  names.select { |n| n.downcase.include?(needle) }
end

.known_dead(members, sweep) ⇒ Object

Names the last probe of this pool found dead, probing (and sweeping) again once PROBE_INTERVAL has passed.



121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/wurk/web/enterprise.rb', line 121

def known_dead(members, sweep)
  pool = Wurk.redis_pool
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
  @probe_lock.synchronize do
    probe = @probe
    return probe[:dead] if probe && probe[:pool].equal?(pool) && now - probe[:at] < PROBE_INTERVAL

    dead = members - (sweep ? sweep(members) : live(members))
    @probe = { pool: pool, at: now, dead: dead }
    dead
  end
end

.list(filter: nil, sweep: !Wurk::Web.config.read_only?)) ⇒ Array<String>

Listing is also the sweep. lmtr-list membership has no TTL of its own while lmtr:<name> expires with the limiter's ttl, so every interpolated name (stripe-#{user_id}) the spec blesses would leave a permanent member behind. Names whose metadata is gone are dropped from the SET and from the result, so the dashboard also stops rendering a row with nothing behind it. The liveness probe rides along with the SMEMBERS the listing already pays for — both are O(set) — and once the sweep has run the set is back down to live limiters.

A read-only dashboard is a viewer, so it filters the dead names out of what it returns but leaves the SET alone: GET must not write.

Returns:

  • (Array<String>) —

    live limiter names, optionally filtered to those whose name contains the case-insensitive substring filter.



44
45
46
47
# File 'lib/wurk/web/enterprise.rb', line 44

def list(filter: nil, sweep: !Wurk::Web.config.read_only?)
  members = Wurk.redis(idempotent: true) { |c| c.call('SMEMBERS', Wurk::Limiter::LIST_KEY) }
  filtered((sweep ? sweep(members) : live(members)).sort, filter)
end

.live(names) ⇒ Object



54
55
56
# File 'lib/wurk/web/enterprise.rb', line 54

def live(names)
  names.each_slice(SWEEP_BATCH).flat_map { |slice| partition_live(slice).first }
end

.meta_key(name) ⇒ Object



151
152
153
# File 'lib/wurk/web/enterprise.rb', line 151

def meta_key(name)
  "lmtr:#{name}"
end

.metadata(name) ⇒ Object



84
85
86
87
# File 'lib/wurk/web/enterprise.rb', line 84

def (name)
  raw = Wurk.redis(idempotent: true) { |c| c.call('HGETALL', meta_key(name)) }
  raw.is_a?(Array) ? raw.each_slice(2).to_h : raw
end

.metadata_rows(names) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
# File 'lib/wurk/web/enterprise.rb', line 134

def (names)
  return [] if names.empty?

  raws = Wurk.redis(idempotent: true) do |c|
    c.pipelined { |pipe| names.each { |n| pipe.call('HGETALL', meta_key(n)) } }
  end
  names.zip(raws).filter_map do |name, raw|
    meta = raw.is_a?(Array) ? raw.each_slice(2).to_h : raw
    [name, meta] unless meta.empty?
  end
end

.page(filter:, offset:, count:, sweep: !Wurk::Web.config.read_only?)) ⇒ Array(Integer, Array<Array(String, Hash)>)

The dashboard's listing: count live limiters from offset of the sorted, filtered names, each [name, metadata], and how many names there are. #list probes every member's metadata (an EXISTS each) on every call; here that probe — and, with sweep, the removal of the dead — runs at most once per PROBE_INTERVAL per process, and pages in between pay one SMEMBERS and one pipelined HGETALL for their rows. The cost of that: a limiter whose metadata expired since the last probe counts in total and is left off its page, and a name the probe found dead and that registers again stays hidden until the next probe.

Returns:

  • (Array(Integer, Array<Array(String, Hash)>))


106
107
108
109
110
# File 'lib/wurk/web/enterprise.rb', line 106

def page(filter:, offset:, count:, sweep: !Wurk::Web.config.read_only?)
  members = Wurk.redis(idempotent: true) { |c| c.call('SMEMBERS', Wurk::Limiter::LIST_KEY) }
  names = filtered((members - known_dead(members, sweep)).sort, filter)
  [names.size, (names[offset, count] || [])]
end

.partition_live(names) ⇒ Object



64
65
66
67
68
69
# File 'lib/wurk/web/enterprise.rb', line 64

def partition_live(names)
  flags = Wurk.redis(idempotent: true) do |c|
    c.pipelined { |pipe| names.each { |n| pipe.call('EXISTS', meta_key(n)) } }
  end
  names.partition.with_index { |_, i| flags[i].to_i == 1 }
end

.rebuild(name) ⇒ Object

Read-only reconstruction (register: false) from the persisted metadata. Returns nil when the meta HASH is gone or malformed — the name a caller holds came from a page list rendered earlier, and the metadata can expire between the two.



172
173
174
175
176
177
178
179
180
# File 'lib/wurk/web/enterprise.rb', line 172

def rebuild(name)
  meta = (name)
  return nil if meta.nil? || meta.empty?

  options = meta['options'] ? ::JSON.parse(meta['options']) : {}
  Wurk::Limiter.build(name, meta['type'], options)
rescue StandardError
  nil
end

.reset(name) ⇒ Object

Stats-key reset: drops every lmtr-stats: / state key for the named limiter while keeping its metadata + LIST membership so the row remains in the UI. Mirrors the §1.5 #reset surface — the name is wire-compat, so the trailing-? rule doesn't apply here.



159
160
161
162
163
164
165
166
# File 'lib/wurk/web/enterprise.rb', line 159

def reset(name)
  limiter = rebuild(name)
  # Reconstruct the limiter and delegate: only the type itself knows
  # its state keys. A bucket's counter lives at `lmtr-b:<name>:<epoch>`,
  # so the bare-prefix DEL below never touched it.
  limiter ? limiter.reset : reset_by_prefix(name)
  true
end

.reset_by_prefix(name) ⇒ Object

Metadata-less fallback: wipe every type's un-suffixed state key.



183
184
185
186
187
188
# File 'lib/wurk/web/enterprise.rb', line 183

def reset_by_prefix(name)
  Wurk.redis do |c|
    %W[lmtr-cs:#{name} lmtr-w:#{name} lmtr-l:#{name}
       lmtr-p:#{name} lmtr-stats:#{name}].each { |k| c.call('DEL', k) }
  end
end

.reset_probe! ⇒ Object

Forgets the last probe, so the next #page probes. For tests.



147
148
149
# File 'lib/wurk/web/enterprise.rb', line 147

def reset_probe!
  @probe_lock.synchronize { @probe = nil }
end

.sweep(names) ⇒ Object

Drop the names whose metadata is gone, return the ones still live.



50
51
52
# File 'lib/wurk/web/enterprise.rb', line 50

def sweep(names)
  names.each_slice(SWEEP_BATCH).flat_map { |slice| sweep_slice(slice) }
end

.sweep_batch(names) ⇒ Object

Claims apply-safety, unlike ProcessSet's comparable prune: the script re-decides liveness inside its own atomic step, so a replay can only remove names that are still dead.



74
75
76
77
78
79
80
81
82
# File 'lib/wurk/web/enterprise.rb', line 74

def sweep_batch(names)
  Wurk.redis(idempotent: true) do |c|
    Wurk::Lua::Loader.eval_cached(
      c, :limiter_list_sweep,
      keys: [Wurk::Limiter::LIST_KEY, *names.map { |n| meta_key(n) }],
      argv: names
    )
  end
end

.sweep_slice(names) ⇒ Object



58
59
60
61
62
# File 'lib/wurk/web/enterprise.rb', line 58

def sweep_slice(names)
  live, dead = partition_live(names)
  sweep_batch(dead) unless dead.empty?
  live
end