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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa14ee5aba79a712b64bd58ff53a40076fd8c4d3f84d6b58757f393175aba7f2
4
- data.tar.gz: 572ed5d1c54e429da795341b6aa725c9187c06e4dca4445eff530d59f3324f5e
3
+ metadata.gz: 4543f4e847d248557306e3d6f7675626d9189e2a17d079e23c94df3f58a389d3
4
+ data.tar.gz: fed3679ff480666c18623114e1f22a618c39357001fa7d8d3f3fa295f2a22f07
5
5
  SHA512:
6
- metadata.gz: 3eb840d78c0218c25882e8f3e757164ca7463b73f246aec0ac41a99c3c54ab3e644c5f59ee0fd695c0ab31818043f976f765c1316930a4ae86985179f92b69ba
7
- data.tar.gz: 338a32c3e8e50b6f4113144b90daf0508a95032ddf3c393317ef1f431671db2d1caf32caf94c5e870181c5cf05e6a483a329bfdb636eacd2b6a4ce646ab34a8b
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
@@ -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
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.111'
4
+ VERSION = '1.4.112'
5
5
  end
data/lib/legion.rb CHANGED
@@ -13,6 +13,8 @@ require 'legion/extensions'
13
13
 
14
14
  module Legion
15
15
  autoload :Region, 'legion/region'
16
+ autoload :Lock, 'legion/lock'
17
+ autoload :Leader, 'legion/leader'
16
18
 
17
19
  attr_reader :service
18
20
 
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.111
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