legion-data 1.10.7 → 1.10.9
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 +12 -0
- data/lib/legion/data/auth_failure_handler.rb +80 -0
- data/lib/legion/data/connection.rb +87 -5
- data/lib/legion/data/settings.rb +22 -0
- data/lib/legion/data/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d929f0ed083e64145e6022ee40af61b677b852513e071ec9a986267837953a2
|
|
4
|
+
data.tar.gz: ed00e7cb0c7670378212cc1f8df39c893f0efbaf7a71961188a411a1956dcab9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdf3cf290669b970b523c3de8e5d6b42b9b14d877bd7260155c2067a708745cd59120605059dc1d5af85fd94f0a63d0c1e4e39ae4b7870409357572085b1d5f2
|
|
7
|
+
data.tar.gz: 3b1c0bb52ba3bfcc0249f6a6a4c0185b213f7e6cd8201989f5cd0279be1d9e1df1e364c0479d48920397f7e693ff5a63f557048acdaaefecdd32651e212922c7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Legion::Data Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.9] - 2026-07-31
|
|
4
|
+
### Fixed
|
|
5
|
+
- **Postgres session timeouts**: Apply `statement_timeout` (default 5000ms) and `lock_timeout` (default 2000ms) via `after_connect` hook on every new Postgres connection. Prevents unbounded `SELECT` or lock-wait from hanging the process indefinitely — the root cause of ~1h50m connection wedges.
|
|
6
|
+
- **TCP keepalives on Postgres**: Wire libpq `keepalives`, `keepalives_idle` (10s), `keepalives_interval` (5s), `keepalives_count` (3), and `tcp_user_timeout` (15000ms) through the Sequel connection hash. Detects half-open TCP sockets within ~25s instead of relying on OS defaults (often 2+ hours).
|
|
7
|
+
- **Force-disconnect on shutdown**: `shutdown` now waits up to `shutdown_timeout` (default 5s) for checked-out connections to return, then force-disconnects all remaining connections. Previously, `disconnect` only closed idle pool connections — a connection stuck in `select()` survived forever, hanging process exit.
|
|
8
|
+
- **Adapter timeout defaults tightened**: Postgres `connect_timeout` reduced from 20s to 5s; MySQL `connect_timeout` reduced from 120s to 5s, `read_timeout` and `write_timeout` default to 5s. The old 20s/120s values allowed connection attempts to block worker threads far too long under network partition.
|
|
9
|
+
- **MySQL read/write timeout wiring preserved**: `read_timeout` and `write_timeout` remain in `ADAPTER_KEYS[:mysql2]` where Sequel's mysql2 adapter honors them. For Postgres, the equivalent is `statement_timeout` (query bound) plus TCP keepalives (socket bound) — `read_timeout` is not a libpq/Sequel-postgres concept, so it is correctly absent from the Postgres key list.
|
|
10
|
+
|
|
11
|
+
## [1.10.8] - 2026-07-23
|
|
12
|
+
### Fixed
|
|
13
|
+
- Detect auth failures (`role does not exist`, `password authentication failed`) on Sequel pool connections and trigger immediate Vault lease reissue via LeaseManager instead of retrying dead credentials forever. 30-second cooldown prevents reissue storms during bulk credential rotation.
|
|
14
|
+
|
|
3
15
|
## [1.10.7] - 2026-07-15
|
|
4
16
|
### Fixed
|
|
5
17
|
- Fail loud with actionable error when unresolved lease:// credentials reach connection
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Data
|
|
7
|
+
module AuthFailureHandler
|
|
8
|
+
extend Legion::Logging::Helper
|
|
9
|
+
|
|
10
|
+
AUTH_FAILURE_PATTERNS = [
|
|
11
|
+
/role .* does not exist/i,
|
|
12
|
+
/password authentication failed/i,
|
|
13
|
+
/authentication failed/i,
|
|
14
|
+
/no pg_hba\.conf entry/i,
|
|
15
|
+
/permission denied for table/i,
|
|
16
|
+
/permission denied for relation/i
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
REISSUE_COOLDOWN = 30
|
|
20
|
+
|
|
21
|
+
@last_reissue_at = nil
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
|
|
24
|
+
module SequelHook
|
|
25
|
+
def connect(server)
|
|
26
|
+
super
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Legion::Data::AuthFailureHandler.handle(e)
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def raise_error(exception, opts = Sequel::OPTS)
|
|
33
|
+
Legion::Data::AuthFailureHandler.handle(exception)
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def install(sequel_db)
|
|
40
|
+
sequel_db.singleton_class.prepend(SequelHook)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def handle(error)
|
|
44
|
+
return unless auth_failure?(error)
|
|
45
|
+
return if on_cooldown?
|
|
46
|
+
|
|
47
|
+
request_reissue(error)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def auth_failure?(error)
|
|
51
|
+
message = error.message.to_s
|
|
52
|
+
AUTH_FAILURE_PATTERNS.any? { |pattern| message.match?(pattern) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_cooldown?
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
return false unless @last_reissue_at
|
|
58
|
+
|
|
59
|
+
(Time.now - @last_reissue_at) < REISSUE_COOLDOWN
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request_reissue(error, adapter: :postgresql)
|
|
64
|
+
@mutex.synchronize { @last_reissue_at = Time.now }
|
|
65
|
+
log.error("Legion::Data auth failure detected: #{error.message} — requesting lease reissue")
|
|
66
|
+
|
|
67
|
+
return unless defined?(Legion::Crypt::LeaseManager)
|
|
68
|
+
|
|
69
|
+
Legion::Crypt::LeaseManager.instance.reissue_lease(adapter)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
handle_exception(e, level: :error, handled: true, operation: :auth_failure_reissue)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reset!
|
|
75
|
+
@mutex.synchronize { @last_reissue_at = nil }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'legion/logging/helper'
|
|
4
|
+
require 'legion/data/auth_failure_handler'
|
|
4
5
|
|
|
5
6
|
require 'fileutils'
|
|
6
7
|
require 'sequel'
|
|
@@ -18,14 +19,18 @@ module Legion
|
|
|
18
19
|
|
|
19
20
|
ADAPTER_KEYS = {
|
|
20
21
|
sqlite: %i[timeout readonly disable_dqs],
|
|
21
|
-
postgres: %i[connect_timeout sslmode sslrootcert search_path
|
|
22
|
+
postgres: %i[connect_timeout sslmode sslrootcert search_path
|
|
23
|
+
keepalives keepalives_idle keepalives_interval keepalives_count
|
|
24
|
+
tcp_user_timeout],
|
|
22
25
|
mysql2: %i[connect_timeout read_timeout write_timeout encoding sql_mode]
|
|
23
26
|
}.freeze
|
|
24
27
|
|
|
25
28
|
ADAPTER_DEFAULTS = {
|
|
26
29
|
sqlite: { timeout: 5000, readonly: false, disable_dqs: true },
|
|
27
|
-
postgres: { connect_timeout:
|
|
28
|
-
|
|
30
|
+
postgres: { connect_timeout: 5, sslmode: 'disable',
|
|
31
|
+
keepalives: 1, keepalives_idle: 10, keepalives_interval: 5,
|
|
32
|
+
keepalives_count: 3, tcp_user_timeout: 15_000 },
|
|
33
|
+
mysql2: { connect_timeout: 5, read_timeout: 5, write_timeout: 5, encoding: 'utf8mb4' }
|
|
29
34
|
}.freeze
|
|
30
35
|
|
|
31
36
|
QUERY_LOG_DIR = File.expand_path('~/.legionio/logs').freeze
|
|
@@ -271,7 +276,10 @@ module Legion
|
|
|
271
276
|
end
|
|
272
277
|
|
|
273
278
|
def shutdown
|
|
274
|
-
@sequel
|
|
279
|
+
if @sequel
|
|
280
|
+
timeout = Legion::Settings[:data][:shutdown_timeout]
|
|
281
|
+
force_disconnect_pool(timeout: timeout)
|
|
282
|
+
end
|
|
275
283
|
@query_file_logger&.close
|
|
276
284
|
@query_file_logger = nil
|
|
277
285
|
@fallback_active = false
|
|
@@ -299,6 +307,7 @@ module Legion
|
|
|
299
307
|
@sequel.opts[:password] = new_pass
|
|
300
308
|
|
|
301
309
|
@sequel.disconnect
|
|
310
|
+
expire_all_pooled_connections
|
|
302
311
|
|
|
303
312
|
@sequel.test_connection
|
|
304
313
|
log.info("reconnect_with_fresh_creds: rotated credentials (#{old_user} → #{new_user})")
|
|
@@ -309,6 +318,16 @@ module Legion
|
|
|
309
318
|
false
|
|
310
319
|
end
|
|
311
320
|
|
|
321
|
+
def expire_all_pooled_connections
|
|
322
|
+
pool = @sequel.pool
|
|
323
|
+
return unless pool.instance_variable_defined?(:@connection_expiration_timestamps)
|
|
324
|
+
|
|
325
|
+
timestamps = pool.instance_variable_get(:@connection_expiration_timestamps)
|
|
326
|
+
timestamps.each_key { |conn| timestamps[conn] = [0, 0].freeze }
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
handle_exception(e, level: :warn, handled: true, operation: :expire_all_pooled_connections)
|
|
329
|
+
end
|
|
330
|
+
|
|
312
331
|
def connect_with_replicas
|
|
313
332
|
return unless adapter == :postgres
|
|
314
333
|
|
|
@@ -397,15 +416,49 @@ module Legion
|
|
|
397
416
|
conn_host = actual[:host] || '127.0.0.1'
|
|
398
417
|
conn_port = actual[:port]
|
|
399
418
|
conn_db = actual[:database] || actual[:db]
|
|
400
|
-
|
|
419
|
+
lease_id = current_lease_id
|
|
420
|
+
msg = "Connected to #{adapter}://#{conn_user}@#{conn_host}:#{conn_port}/#{conn_db}"
|
|
421
|
+
msg += " lease_id=#{lease_id}" if lease_id
|
|
422
|
+
log.info msg
|
|
401
423
|
end
|
|
402
424
|
end
|
|
403
425
|
|
|
426
|
+
def current_lease_id
|
|
427
|
+
return unless defined?(Legion::Crypt::LeaseManager)
|
|
428
|
+
|
|
429
|
+
Legion::Crypt::LeaseManager.instance.active_leases.dig(:postgresql, :lease_id)
|
|
430
|
+
rescue StandardError => e
|
|
431
|
+
handle_exception(e, level: :warn, handled: true, operation: :current_lease_id)
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
|
|
404
435
|
def dev_fallback?
|
|
405
436
|
data_settings = Legion::Settings[:data]
|
|
406
437
|
data_settings[:dev_mode] == true && data_settings[:dev_fallback] != false
|
|
407
438
|
end
|
|
408
439
|
|
|
440
|
+
def force_disconnect_pool(timeout:)
|
|
441
|
+
pool = @sequel.pool
|
|
442
|
+
in_use = pool.size - (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
|
|
443
|
+
|
|
444
|
+
if in_use.positive?
|
|
445
|
+
log.warn("Legion::Data shutdown: #{in_use} connection(s) still checked out, waiting up to #{timeout}s")
|
|
446
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout
|
|
447
|
+
while ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < deadline
|
|
448
|
+
break if pool.size <= (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
|
|
449
|
+
|
|
450
|
+
sleep 0.1
|
|
451
|
+
end
|
|
452
|
+
remaining = pool.size - (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
|
|
453
|
+
log.warn("Legion::Data shutdown: force-disconnecting #{remaining} wedged connection(s)") if remaining.positive?
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
@sequel.disconnect
|
|
457
|
+
rescue StandardError => e
|
|
458
|
+
handle_exception(e, level: :warn, handled: true, operation: :force_disconnect_pool)
|
|
459
|
+
@sequel&.disconnect rescue nil # rubocop:disable Style/RescueModifier
|
|
460
|
+
end
|
|
461
|
+
|
|
409
462
|
def sqlite_path
|
|
410
463
|
path = Legion::Settings[:data][:creds][:database] || 'legionio.db'
|
|
411
464
|
return path if File.absolute_path?(path)
|
|
@@ -489,6 +542,11 @@ module Legion
|
|
|
489
542
|
tuning[:connection_expiration] = data[:connection_expiration]
|
|
490
543
|
tuning[:connection_expiration_timeout] = data[:connection_expiration_timeout]
|
|
491
544
|
|
|
545
|
+
# Session timeouts (postgres)
|
|
546
|
+
tuning[:statement_timeout] = data[:statement_timeout]
|
|
547
|
+
tuning[:lock_timeout] = data[:lock_timeout]
|
|
548
|
+
tuning[:shutdown_timeout] = data[:shutdown_timeout]
|
|
549
|
+
|
|
492
550
|
# Adapter-specific (only keys relevant to current adapter)
|
|
493
551
|
defaults = ADAPTER_DEFAULTS.fetch(adapter, {})
|
|
494
552
|
ADAPTER_KEYS.fetch(adapter, []).each do |key|
|
|
@@ -597,6 +655,7 @@ module Legion
|
|
|
597
655
|
if adapter == :postgres
|
|
598
656
|
Sequel.extension(:pg_array)
|
|
599
657
|
@sequel.extension(:pg_array)
|
|
658
|
+
install_postgres_session_timeouts
|
|
600
659
|
end
|
|
601
660
|
|
|
602
661
|
if data[:connection_validation] != false
|
|
@@ -608,10 +667,33 @@ module Legion
|
|
|
608
667
|
@sequel.extension(:connection_expiration)
|
|
609
668
|
@sequel.pool.connection_expiration_timeout = data[:connection_expiration_timeout]
|
|
610
669
|
end
|
|
670
|
+
|
|
671
|
+
install_auth_failure_hook
|
|
611
672
|
rescue StandardError => e
|
|
612
673
|
handle_exception(e, level: :warn, handled: true, operation: :configure_extensions, adapter: adapter)
|
|
613
674
|
end
|
|
614
675
|
|
|
676
|
+
def install_postgres_session_timeouts
|
|
677
|
+
data = Legion::Settings[:data]
|
|
678
|
+
stmt_timeout = data[:statement_timeout]
|
|
679
|
+
lck_timeout = data[:lock_timeout]
|
|
680
|
+
|
|
681
|
+
@sequel.pool.after_connect = proc do |conn|
|
|
682
|
+
conn.exec("SET statement_timeout = '#{stmt_timeout.to_i}ms'") if stmt_timeout
|
|
683
|
+
conn.exec("SET lock_timeout = '#{lck_timeout.to_i}ms'") if lck_timeout
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
log.info("Postgres session timeouts: statement_timeout=#{stmt_timeout}ms, lock_timeout=#{lck_timeout}ms")
|
|
687
|
+
rescue StandardError => e
|
|
688
|
+
handle_exception(e, level: :warn, handled: true, operation: :install_postgres_session_timeouts)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def install_auth_failure_hook
|
|
692
|
+
Legion::Data::AuthFailureHandler.install(@sequel)
|
|
693
|
+
rescue StandardError => e
|
|
694
|
+
handle_exception(e, level: :warn, handled: true, operation: :install_auth_failure_hook)
|
|
695
|
+
end
|
|
696
|
+
|
|
615
697
|
def build_data_logger
|
|
616
698
|
tagged = if defined?(Legion::Logging::TaggedLogger) && respond_to?(:tagged_logger_settings, true)
|
|
617
699
|
Legion::Logging::TaggedLogger.new(
|
data/lib/legion/data/settings.rb
CHANGED
|
@@ -58,6 +58,28 @@ module Legion
|
|
|
58
58
|
connection_expiration: true,
|
|
59
59
|
connection_expiration_timeout: 14_400,
|
|
60
60
|
|
|
61
|
+
# Postgres session timeouts (milliseconds, applied via SET on each new connection).
|
|
62
|
+
# statement_timeout bounds any single query; lock_timeout bounds lock acquisition.
|
|
63
|
+
# These prevent a wedged SELECT or lock wait from hanging the process indefinitely.
|
|
64
|
+
statement_timeout: 5000,
|
|
65
|
+
lock_timeout: 2000,
|
|
66
|
+
|
|
67
|
+
# TCP keepalives for Postgres (libpq connection params).
|
|
68
|
+
# Detect half-open sockets where the peer vanished without FIN/RST.
|
|
69
|
+
# keepalives_idle: seconds before first keepalive probe after idle
|
|
70
|
+
# keepalives_interval: seconds between probes
|
|
71
|
+
# keepalives_count: failed probes before declaring connection dead
|
|
72
|
+
# tcp_user_timeout: total ms for unacked data before kernel drops connection (Linux)
|
|
73
|
+
keepalives: 1,
|
|
74
|
+
keepalives_idle: 10,
|
|
75
|
+
keepalives_interval: 5,
|
|
76
|
+
keepalives_count: 3,
|
|
77
|
+
tcp_user_timeout: 15_000,
|
|
78
|
+
|
|
79
|
+
# Shutdown: max seconds to wait for checked-out connections to return before
|
|
80
|
+
# force-disconnecting them.
|
|
81
|
+
shutdown_timeout: 5,
|
|
82
|
+
|
|
61
83
|
# Adapter-specific (nil = use adapter built-in default)
|
|
62
84
|
connect_timeout: nil,
|
|
63
85
|
read_timeout: nil,
|
data/lib/legion/data/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-data
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.10.
|
|
4
|
+
version: 1.10.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -124,6 +124,7 @@ files:
|
|
|
124
124
|
- lib/legion/data/archiver.rb
|
|
125
125
|
- lib/legion/data/audit_log_hash_chain.rb
|
|
126
126
|
- lib/legion/data/audit_record.rb
|
|
127
|
+
- lib/legion/data/auth_failure_handler.rb
|
|
127
128
|
- lib/legion/data/connection.rb
|
|
128
129
|
- lib/legion/data/encryption/cipher.rb
|
|
129
130
|
- lib/legion/data/encryption/key_provider.rb
|