legionio 1.4.111 → 1.4.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/legion/extensions/actors/singleton.rb +50 -0
- data/lib/legion/extensions/actors/subscription.rb +13 -0
- data/lib/legion/leader.rb +78 -0
- data/lib/legion/lock.rb +77 -0
- data/lib/legion/service.rb +3 -0
- data/lib/legion/version.rb +1 -1
- data/lib/legion.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4543f4e847d248557306e3d6f7675626d9189e2a17d079e23c94df3f58a389d3
|
|
4
|
+
data.tar.gz: fed3679ff480666c18623114e1f22a618c39357001fa7d8d3f3fa295f2a22f07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b8bb2eaf61edb8d41812ac9fd1b79abbb4d2d26a0f563b95b42e20d312ae4076c835a53d7c563b0f869db37377ce79682699f01c0d10647be897acc7bb72bb9
|
|
7
|
+
data.tar.gz: 67db3ddd7c06cf4e77804c040b228cb4319898d510ae9142a9e6d8f477a8f8e7eab8b886444a3356f681088e6d535ffe1f99342b35b7e4efcf6ffc6a9cc8645b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.112] - 2026-03-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Legion::Lock` distributed locking module (Redis SET NX PX acquire, Lua compare-and-delete release)
|
|
7
|
+
- `Legion::Leader` leader election module with periodic renewal via distributed lock
|
|
8
|
+
- `Legion::Extensions::Actors::Singleton` mixin for singleton actor enforcement (one instance per cluster)
|
|
9
|
+
- `Legion::Leader.reset!` called in shutdown sequence to release leadership before process exit
|
|
10
|
+
|
|
3
11
|
## [1.4.111] - 2026-03-21
|
|
4
12
|
|
|
5
13
|
### Added
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Actors
|
|
6
|
+
module Singleton
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.prepend(ExecutionGuard)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def singleton_role
|
|
12
|
+
self.class.name&.gsub('::', '_')&.downcase || 'unknown'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def singleton_ttl
|
|
16
|
+
[time * 3, 30].max
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module ExecutionGuard
|
|
20
|
+
def initialize(**opts)
|
|
21
|
+
@leader_token = nil
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def skip_or_run(&)
|
|
28
|
+
return super unless defined?(Legion::Lock)
|
|
29
|
+
|
|
30
|
+
role = singleton_role
|
|
31
|
+
ttl_ms = singleton_ttl * 1000
|
|
32
|
+
|
|
33
|
+
unless @leader_token
|
|
34
|
+
@leader_token = Legion::Lock.acquire("leader:#{role}", ttl: ttl_ms)
|
|
35
|
+
return unless @leader_token
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
extended = Legion::Lock.extend_lock("leader:#{role}", @leader_token, ttl: ttl_ms)
|
|
39
|
+
unless extended
|
|
40
|
+
@leader_token = Legion::Lock.acquire("leader:#{role}", ttl: ttl_ms)
|
|
41
|
+
return unless @leader_token
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -123,6 +123,7 @@ module Legion
|
|
|
123
123
|
if affinity_result == :remote
|
|
124
124
|
Legion::Logging.debug 'Processing remote-region message ' \
|
|
125
125
|
"(region=#{message[:region]}, affinity=#{message[:region_affinity]})"
|
|
126
|
+
record_cross_region_metric(message)
|
|
126
127
|
end
|
|
127
128
|
|
|
128
129
|
if use_runner?
|
|
@@ -144,6 +145,18 @@ module Legion
|
|
|
144
145
|
|
|
145
146
|
private
|
|
146
147
|
|
|
148
|
+
def record_cross_region_metric(message)
|
|
149
|
+
return unless defined?(Legion::Extensions::Telemetry::Runners::Telemetry)
|
|
150
|
+
|
|
151
|
+
Legion::Extensions::Telemetry::Runners::Telemetry.record_cross_region(
|
|
152
|
+
from_region: message[:region],
|
|
153
|
+
to_region: Legion::Region.current,
|
|
154
|
+
affinity: message[:region_affinity]
|
|
155
|
+
)
|
|
156
|
+
rescue StandardError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
147
160
|
def check_region_affinity(message)
|
|
148
161
|
return :local unless defined?(Legion::Region)
|
|
149
162
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
require_relative 'lock'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Leader
|
|
8
|
+
class << self
|
|
9
|
+
def elect(role, ttl: 30)
|
|
10
|
+
ttl_ms = ttl * 1000
|
|
11
|
+
token = Legion::Lock.acquire("leader:#{role}", ttl: ttl_ms)
|
|
12
|
+
return nil unless token
|
|
13
|
+
|
|
14
|
+
@leaders ||= {}
|
|
15
|
+
@leaders[role.to_sym] = { token: token, ttl_ms: ttl_ms }
|
|
16
|
+
token
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def leader?(role)
|
|
20
|
+
return false unless @leaders&.dig(role.to_sym, :token)
|
|
21
|
+
|
|
22
|
+
Legion::Lock.locked?("leader:#{role}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resign(role)
|
|
26
|
+
return false unless @leaders&.dig(role.to_sym)
|
|
27
|
+
|
|
28
|
+
entry = @leaders.delete(role.to_sym)
|
|
29
|
+
stop_renewal(role)
|
|
30
|
+
Legion::Lock.release("leader:#{role}", entry[:token])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_leadership(role, ttl: 30)
|
|
34
|
+
token = elect(role, ttl: ttl)
|
|
35
|
+
raise Legion::Lock::NotAcquired, "could not elect leader for: #{role}" unless token
|
|
36
|
+
|
|
37
|
+
start_renewal(role, ttl)
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
resign(role)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset!
|
|
44
|
+
@leaders&.each_key { |role| resign(role) }
|
|
45
|
+
@leaders = {}
|
|
46
|
+
@renewals&.each_value(&:shutdown)
|
|
47
|
+
@renewals = {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def start_renewal(role, ttl)
|
|
53
|
+
@renewals ||= {}
|
|
54
|
+
interval = [ttl / 3, 1].max
|
|
55
|
+
entry = @leaders[role.to_sym]
|
|
56
|
+
return unless entry
|
|
57
|
+
|
|
58
|
+
@renewals[role.to_sym] = Concurrent::TimerTask.new(execution_interval: interval) do
|
|
59
|
+
success = Legion::Lock.extend_lock("leader:#{role}", entry[:token], ttl: entry[:ttl_ms])
|
|
60
|
+
unless success
|
|
61
|
+
log_warn("Lost leadership for #{role}")
|
|
62
|
+
@renewals[role.to_sym]&.shutdown
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
@renewals[role.to_sym].execute
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stop_renewal(role)
|
|
69
|
+
@renewals ||= {}
|
|
70
|
+
@renewals.delete(role.to_sym)&.shutdown
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log_warn(msg)
|
|
74
|
+
Legion::Logging.warn(msg) if defined?(Legion::Logging)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/legion/lock.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Lock
|
|
7
|
+
class NotAcquired < StandardError; end
|
|
8
|
+
|
|
9
|
+
RELEASE_SCRIPT = <<~LUA
|
|
10
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
11
|
+
return redis.call("del", KEYS[1])
|
|
12
|
+
else
|
|
13
|
+
return 0
|
|
14
|
+
end
|
|
15
|
+
LUA
|
|
16
|
+
|
|
17
|
+
EXTEND_SCRIPT = <<~LUA
|
|
18
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
19
|
+
return redis.call("pexpire", KEYS[1], ARGV[2])
|
|
20
|
+
else
|
|
21
|
+
return 0
|
|
22
|
+
end
|
|
23
|
+
LUA
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def acquire(name, ttl: 30_000)
|
|
27
|
+
token = SecureRandom.uuid
|
|
28
|
+
key = lock_key(name)
|
|
29
|
+
result = with_redis { |conn| conn.set(key, token, nx: true, px: ttl) }
|
|
30
|
+
result ? token : nil
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def release(name, token)
|
|
36
|
+
key = lock_key(name)
|
|
37
|
+
result = with_redis { |conn| conn.eval(RELEASE_SCRIPT, keys: [key], argv: [token]) }
|
|
38
|
+
result == 1
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with_lock(name, ttl: 30_000)
|
|
44
|
+
token = acquire(name, ttl: ttl)
|
|
45
|
+
raise NotAcquired, "could not acquire lock: #{name}" unless token
|
|
46
|
+
|
|
47
|
+
yield
|
|
48
|
+
ensure
|
|
49
|
+
release(name, token) if token
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def extend_lock(name, token, ttl: 30_000)
|
|
53
|
+
key = lock_key(name)
|
|
54
|
+
result = with_redis { |conn| conn.eval(EXTEND_SCRIPT, keys: [key], argv: [token, ttl.to_s]) }
|
|
55
|
+
result == 1
|
|
56
|
+
rescue StandardError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def locked?(name)
|
|
61
|
+
with_redis { |conn| conn.exists?(lock_key(name)) }
|
|
62
|
+
rescue StandardError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def lock_key(name)
|
|
69
|
+
"legion:lock:#{name}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def with_redis(&)
|
|
73
|
+
Legion::Cache.client.with(&)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/legion/service.rb
CHANGED
|
@@ -254,6 +254,7 @@ module Legion
|
|
|
254
254
|
def register_logging_hooks
|
|
255
255
|
return unless Legion::Transport::Connection.session_open?
|
|
256
256
|
|
|
257
|
+
require 'legion/transport/exchanges/logging' unless defined?(Legion::Transport::Exchanges::Logging)
|
|
257
258
|
exchange = Legion::Transport::Exchanges::Logging.new
|
|
258
259
|
|
|
259
260
|
%i[fatal error warn].each do |level|
|
|
@@ -381,6 +382,8 @@ module Legion
|
|
|
381
382
|
Legion::Data.shutdown if Legion::Settings[:data][:connected]
|
|
382
383
|
Legion::Readiness.mark_not_ready(:data)
|
|
383
384
|
|
|
385
|
+
Legion::Leader.reset! if defined?(Legion::Leader)
|
|
386
|
+
|
|
384
387
|
Legion::Cache.shutdown
|
|
385
388
|
Legion::Readiness.mark_not_ready(:cache)
|
|
386
389
|
|
data/lib/legion/version.rb
CHANGED
data/lib/legion.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legionio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.4.
|
|
4
|
+
version: 1.4.112
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -614,6 +614,7 @@ files:
|
|
|
614
614
|
- lib/legion/extensions/actors/nothing.rb
|
|
615
615
|
- lib/legion/extensions/actors/once.rb
|
|
616
616
|
- lib/legion/extensions/actors/poll.rb
|
|
617
|
+
- lib/legion/extensions/actors/singleton.rb
|
|
617
618
|
- lib/legion/extensions/actors/subscription.rb
|
|
618
619
|
- lib/legion/extensions/builders/actors.rb
|
|
619
620
|
- lib/legion/extensions/builders/base.rb
|
|
@@ -644,7 +645,9 @@ files:
|
|
|
644
645
|
- lib/legion/helpers/context.rb
|
|
645
646
|
- lib/legion/ingress.rb
|
|
646
647
|
- lib/legion/isolation.rb
|
|
648
|
+
- lib/legion/leader.rb
|
|
647
649
|
- lib/legion/lex.rb
|
|
650
|
+
- lib/legion/lock.rb
|
|
648
651
|
- lib/legion/metrics.rb
|
|
649
652
|
- lib/legion/notebook/generator.rb
|
|
650
653
|
- lib/legion/notebook/parser.rb
|