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://...)



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

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 --



77
78
79
80
81
82
83
84
# File 'lib/ask/state/providers/redis.rb', line 77

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



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

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

#closeObject



154
155
156
# File 'lib/ask/state/providers/redis.rb', line 154

def close
  @redis&.close
end

#delete(key) ⇒ Object



47
48
49
50
51
# File 'lib/ask/state/providers/redis.rb', line 47

def delete(key)
  # delete removes everything under the key, including ordered
  # lists (consumers store event feeds as lists).
  @redis.call("DEL", prefixed(key), prefixed("list:#{key}"))
end

#dequeue(queue) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
# File 'lib/ask/state/providers/redis.rb', line 112

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 --



103
104
105
106
107
108
109
110
# File 'lib/ask/state/providers/redis.rb', line 103

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)


64
65
66
# File 'lib/ask/state/providers/redis.rb', line 64

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

#get(key) ⇒ Object

-- key-value --



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

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

#keys(pattern: nil) ⇒ Object



68
69
70
71
72
73
# File 'lib/ask/state/providers/redis.rb', line 68

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 --



130
131
132
133
134
135
# File 'lib/ask/state/providers/redis.rb', line 130

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



137
138
139
140
# File 'lib/ask/state/providers/redis.rb', line 137

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



142
143
144
145
# File 'lib/ask/state/providers/redis.rb', line 142

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

#queue_depth(queue) ⇒ Object



124
125
126
# File 'lib/ask/state/providers/redis.rb', line 124

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

#release_lock(key, lock) ⇒ Object



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

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



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

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



53
54
55
56
57
# File 'lib/ask/state/providers/redis.rb', line 53

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

#setup!Object

Idempotent setup. No-op for Redis — no schema to create.



150
151
152
# File 'lib/ask/state/providers/redis.rb', line 150

def setup!
  true
end