Class: AgentsControl::Store

Inherits:
Object
  • Object
show all
Defined in:
lib/agents_control/store.rb

Overview

State that survives a daemon restart.

Needed for two reasons at once:

1. Telegram limits button `callback_data` to 64 bytes. A session
 identifier won't fit there, let alone an action's text — a button
 carries only a short key, and the action itself lives here.
2. While the owner is out, the daemon can crash and come back up.
 Pending questions have to survive that, or the agent is left
 waiting forever.

There are dozens of records here, not millions, and only one process writes them — so an atomically-replaced file and a mutex, not a database.

Constant Summary collapse

DEFAULT_TTL =
3600

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path: self.class.path, clock: -> { Time.now.to_i }) ⇒ Store

Returns a new instance of Store.



31
32
33
34
35
# File 'lib/agents_control/store.rb', line 31

def initialize(path: self.class.path, clock: -> { Time.now.to_i })
  @path = path
  @clock = clock
  @mutex = Mutex.new
end

Instance Attribute Details

#pathObject (readonly)

Returns the value of attribute path.



29
30
31
# File 'lib/agents_control/store.rb', line 29

def path
  @path
end

Class Method Details

.pathObject



24
25
26
27
# File 'lib/agents_control/store.rb', line 24

def self.path
  base = ENV["XDG_STATE_HOME"] || File.expand_path("~/.local/state")
  File.join(base, "agents_control", "store.json")
end

Instance Method Details

#allObject

All live records — for recovering state after a restart.



79
80
81
82
# File 'lib/agents_control/store.rb', line 79

def all
  read.reject { |_key, entry| expired?(entry) }
       .transform_values { |entry| entry["value"] }
end

#delete(key) ⇒ Object



74
75
76
# File 'lib/agents_control/store.rb', line 74

def delete(key)
  write { |data| data.delete(key) }
end

#get(key) ⇒ Object



49
50
51
52
53
54
# File 'lib/agents_control/store.rb', line 49

def get(key)
  entry = read[key]
  return nil if entry.nil? || expired?(entry)

  entry["value"]
end

#pruneObject



84
85
86
# File 'lib/agents_control/store.rb', line 84

def prune
  write { |data| data.reject! { |_key, entry| expired?(entry) } }
end

#put(value, ttl: DEFAULT_TTL, key: nil) ⇒ Object

Short random key for a button. Eight base36 characters is ~41 bits, which is plenty: keys live minutes and are checked one at a time.



39
40
41
42
43
44
45
46
47
# File 'lib/agents_control/store.rb', line 39

def put(value, ttl: DEFAULT_TTL, key: nil)
  key ||= SecureRandom.alphanumeric(8).downcase

  write do |data|
    data[key] = { "value" => value, "expires_at" => now + ttl }
  end

  key
end

#take(key) ⇒ Object

Take the value and delete it in the same step.

This is exactly how a button press is handled: Telegram redelivers the callback if the connection drops, and a finger can tap twice. The first call gets the value, every call after gets nil, and the action never runs a second time. The check and the delete happen under one mutex, or two simultaneous presses could both see the value. The value has to come back as the block's result, not via return: an early return would unwind past the file write, the delete would never reach disk, and the button would fire again after a restart.



66
67
68
69
70
71
72
# File 'lib/agents_control/store.rb', line 66

def take(key)
  write do |data|
    entry = data.delete(key)

    entry.nil? || expired?(entry) ? nil : entry["value"]
  end
end