Class: Cymometer::Counter

Inherits:
Object
  • Object
show all
Defined in:
lib/cymometer/counter.rb

Defined Under Namespace

Classes: LimitExceeded

Constant Summary collapse

DEFAULT_WINDOW =

Default window is 1 hour

3600
INCREMENT_LUA_SCRIPT =
"  local key = KEYS[1]\n  local current_time = tonumber(ARGV[1])\n  local window = tonumber(ARGV[2])\n  local limit = tonumber(ARGV[3])\n\n  -- Remove entries older than the window size\n  redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window)\n\n  -- Get the current count\n  local count = redis.call('ZCOUNT', key, current_time - window, '+inf')\n\n  if count >= limit then\n    -- Limit exceeded, do not increment\n    return {0, count}\n  else\n    -- Increment the counter\n    redis.call('ZADD', key, current_time, current_time)\n    redis.call('EXPIRE', key, math.floor(window / 1000000))\n    return {1, count + 1}\n  end\n".freeze
DECREMENT_LUA_SCRIPT =
"  local key = KEYS[1]\n  local current_time = tonumber(ARGV[1])\n  local window = tonumber(ARGV[2])\n\n  -- Remove entries older than the window size\n  redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window)\n\n  -- Attempt to remove the entry with the lowest score (oldest entry)\n  local entries = redis.call('ZRANGEBYSCORE', key, current_time - window, '+inf', 'LIMIT', 0, 1)\n\n  if next(entries) == nil then\n    -- No entries to remove, safe no-op\n    local count = 0\n    return {1, count}\n  else\n    local member = entries[1]\n    redis.call('ZREM', key, member)\n    local count = redis.call('ZCOUNT', key, current_time - window, '+inf')\n    return {1, count}\n  end\n".freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key_namespace: nil, key: nil, limit: nil, window: nil, redis: nil) ⇒ Counter

Returns a new instance of Counter.



65
66
67
68
69
70
# File 'lib/cymometer/counter.rb', line 65

def initialize(key_namespace: nil, key: nil, limit: nil, window: nil, redis: nil)
  @redis = redis || Cymometer.redis
  @key = "#{key_namespace || "cymometer"}:#{key || generate_key}"
  @window = window || DEFAULT_WINDOW
  @limit = limit || 1
end

Class Attribute Details

.decrement_shaObject (readonly)

Returns the value of attribute decrement_sha.



62
63
64
# File 'lib/cymometer/counter.rb', line 62

def decrement_sha
  @decrement_sha
end

.increment_shaObject (readonly)

Returns the value of attribute increment_sha.



62
63
64
# File 'lib/cymometer/counter.rb', line 62

def increment_sha
  @increment_sha
end

Instance Attribute Details

#keyObject (readonly)

Returns the value of attribute key.



10
11
12
# File 'lib/cymometer/counter.rb', line 10

def key
  @key
end

#limitObject (readonly)

Returns the value of attribute limit.



10
11
12
# File 'lib/cymometer/counter.rb', line 10

def limit
  @limit
end

#windowObject (readonly)

Returns the value of attribute window.



10
11
12
# File 'lib/cymometer/counter.rb', line 10

def window
  @window
end

Instance Method Details

#countObject

Returns the current count



106
107
108
109
110
111
112
113
# File 'lib/cymometer/counter.rb', line 106

def count
  current_time = (Time.now.to_f * 1_000_000).to_i
  window = @window * 1_000_000

  # Clean expired entries
  @redis.zremrangebyscore(@key, 0, current_time - window)
  @redis.zcount(@key, current_time - window, "+inf").to_i
end

#decrement!Object

Atomically decrements the counter by removing the oldest entry



92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/cymometer/counter.rb', line 92

def decrement!
  current_time = (Time.now.to_f * 1_000_000).to_i
  window = @window * 1_000_000

  result = evalsha_with_fallback(
    :decrement,
    keys: [@key],
    argv: [current_time, window]
  )

  result[1].to_i
end

#increment!Object

Atomically increments the counter and checks the limit

Raises:



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/cymometer/counter.rb', line 73

def increment!
  current_time = (Time.now.to_f * 1_000_000).to_i # Microseconds
  window = @window * 1_000_000 # Convert to microseconds

  result = evalsha_with_fallback(
    :increment,
    keys: [@key],
    argv: [current_time, window, @limit]
  )

  success = result[0] == 1
  count = result[1].to_i

  raise LimitExceeded, "Limit of #{@limit} exceeded with count #{count}" unless success

  count
end

#transaction(rollback: true) ⇒ Object

Executes a block if the counter can be incremented. If the block raises an exception, decrements the counter and re-raises the exception.



117
118
119
120
121
122
123
124
125
# File 'lib/cymometer/counter.rb', line 117

def transaction(rollback: true)
  increment!
  begin
    yield
  rescue => e
    decrement! if rollback
    raise e
  end
end