Class: Ask::State::Providers::Redis

Inherits:
Adapter
  • Object
show all
Defined in:
lib/ask/state/providers/redis.rb

Overview

A distributed key-value store backed by Redis.

Implements the full Adapter contract: key-value storage, distributed locking, message queues, and ordered lists. Best suited for multi-process or multi-host deployments where shared state is required.

Examples:

store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
store.set("key", { hello: "world" })
store.get("key")  # => { "hello" => "world" }

Constant Summary collapse

NAMESPACE =
"ask:state"

Instance Method Summary collapse

Constructor Details

#initialize(url: ENV.fetch("REDIS_URL", "redis://localhost:6379")) ⇒ Redis

Creates a new Redis-backed state store.

Parameters:

  • url (String) (defaults to: ENV.fetch("REDIS_URL", "redis://localhost:6379"))

    Redis URL (redis://...)



27
28
29
30
31
# File 'lib/ask/state/providers/redis.rb', line 27

def initialize(url: ENV.fetch("REDIS_URL", "redis://localhost:6379"))
  require "redis"

  @redis = ::Redis.new(url: url)
end

Instance Method Details

#acquire_lock(key, ttl: 10) ⇒ Object

-- distributed locking --



74
75
76
77
78
79
80
81
# File 'lib/ask/state/providers/redis.rb', line 74

def acquire_lock(key, ttl: 10)
  prefixed_key = prefixed("lock:#{key}")
  token = SecureRandom.hex(16)
  expires_at = Time.now + ttl

  acquired = @redis.call("SET", prefixed_key, token, "NX", "EX", ttl) == "OK"
  acquired ? Lock.new(id: key, token: token, expires_at: expires_at) : nil
end

#clearObject



56
57
58
59
# File 'lib/ask/state/providers/redis.rb', line 56

def clear
  keys = @redis.call("KEYS", "#{NAMESPACE}:*")
  @redis.call("DEL", *keys) if keys.any?
end

#closeObject

-- lifecycle --



146
147
148
# File 'lib/ask/state/providers/redis.rb', line 146

def close
  @redis&.close
end

#delete(key) ⇒ Object



46
47
48
# File 'lib/ask/state/providers/redis.rb', line 46

def delete(key)
  @redis.call("DEL", prefixed(key))
end

#dequeue(queue) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
# File 'lib/ask/state/providers/redis.rb', line 109

def dequeue(queue)
  val = @redis.call("LPOP", prefixed("queue:#{queue}"))
  return nil unless val

  entry = JSON.parse(val)
  QueueEntry.new(
    id: entry["id"],
    value: entry["value"],
    enqueued_at: Time.parse(entry["enqueued_at"])
  )
end

#enqueue(queue, value) ⇒ Object

-- message queues --



100
101
102
103
104
105
106
107
# File 'lib/ask/state/providers/redis.rb', line 100

def enqueue(queue, value)
  id = SecureRandom.uuid
  entry = { id: id, value: value, enqueued_at: Time.now.iso8601 }

  @redis.call("RPUSH", prefixed("queue:#{queue}"), JSON.generate(entry))

  QueueEntry.new(id: id, value: value, enqueued_at: Time.now)
end

#exists?(key) ⇒ Boolean

Returns:

  • (Boolean)


61
62
63
# File 'lib/ask/state/providers/redis.rb', line 61

def exists?(key)
  @redis.call("EXISTS", prefixed(key)) == 1
end

#get(key) ⇒ Object

-- key-value --



35
36
37
38
# File 'lib/ask/state/providers/redis.rb', line 35

def get(key)
  val = @redis.call("GET", prefixed(key))
  val ? JSON.parse(val) : nil
end

#keys(pattern: nil) ⇒ Object



65
66
67
68
69
70
# File 'lib/ask/state/providers/redis.rb', line 65

def keys(pattern: nil)
  glob = pattern ? "#{NAMESPACE}:#{pattern}" : "#{NAMESPACE}:*"
  raw = @redis.call("KEYS", glob)
  prefix_len = NAMESPACE.length + 1
  raw.map { |k| k[prefix_len..] }
end

#list_append(key, value, max_length: nil) ⇒ Object

-- ordered lists --



127
128
129
130
131
132
# File 'lib/ask/state/providers/redis.rb', line 127

def list_append(key, value, max_length: nil)
  @redis.call("RPUSH", prefixed("list:#{key}"), JSON.generate(value))
  return unless max_length

  @redis.call("LTRIM", prefixed("list:#{key}"), -max_length, -1)
end

#list_range(key, start = 0, stop = -1)) ⇒ Object



134
135
136
137
# File 'lib/ask/state/providers/redis.rb', line 134

def list_range(key, start = 0, stop = -1)
  items = @redis.call("LRANGE", prefixed("list:#{key}"), start, stop)
  items.map { |item| JSON.parse(item) }
end

#list_remove(key, value) ⇒ Object



139
140
141
142
# File 'lib/ask/state/providers/redis.rb', line 139

def list_remove(key, value)
  serialized = JSON.generate(value)
  @redis.call("LREM", prefixed("list:#{key}"), 0, serialized)
end

#queue_depth(queue) ⇒ Object



121
122
123
# File 'lib/ask/state/providers/redis.rb', line 121

def queue_depth(queue)
  @redis.call("LLEN", prefixed("queue:#{queue}"))
end

#release_lock(key, lock) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/ask/state/providers/redis.rb', line 83

def release_lock(key, lock)
  prefixed_key = prefixed("lock:#{key}")

  # Atomic release: only delete if token matches (Lua script)
  script = <<~LUA
    if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("DEL", KEYS[1])
    else
      return 0
    end
  LUA

  @redis.call("EVAL", script, [1], [prefixed_key, lock.token]) == 1
end

#set(key, value, ttl: nil) ⇒ Object



40
41
42
43
44
# File 'lib/ask/state/providers/redis.rb', line 40

def set(key, value, ttl: nil)
  args = [prefixed(key), JSON.generate(value)]
  args.concat(["EX", ttl.to_i]) if ttl
  @redis.call("SET", *args)
end

#set_if_not_exists(key, value, ttl: nil) ⇒ Object



50
51
52
53
54
# File 'lib/ask/state/providers/redis.rb', line 50

def set_if_not_exists(key, value, ttl: nil)
  args = [prefixed(key), JSON.generate(value)]
  args.concat(["EX", ttl.to_i]) if ttl
  @redis.call("SET", *args, "NX") == "OK"
end