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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc598e354968c28944d6e897feb5853d57987fed49eaf0cd16f319abcfbd8cf8
4
- data.tar.gz: ebc17c4d4b8c49585bcdb0a49803c4988e8d55970ddde56b294746d810193f63
3
+ metadata.gz: 8d929f0ed083e64145e6022ee40af61b677b852513e071ec9a986267837953a2
4
+ data.tar.gz: ed00e7cb0c7670378212cc1f8df39c893f0efbaf7a71961188a411a1956dcab9
5
5
  SHA512:
6
- metadata.gz: 7f36631ecd03c493fa2dfd3b347a4f16765b7900545d31306e4b73f0418a455a99c15ab55a8de83ad86c123befede09a222a67a6095f624923028c585ca9c7d0
7
- data.tar.gz: 9a1783c655df6dce9c23a4a878645cf196e4463d0763d95c380029263a8deeccce7cfce8e66ede4843a04c68e8369232d8d4b785460bd70181d4ad1adcf1c01b
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: 20, sslmode: 'disable' },
28
- mysql2: { connect_timeout: 120, encoding: 'utf8mb4' }
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&.disconnect
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
- log.info "Connected to #{adapter}://#{conn_user}@#{conn_host}:#{conn_port}/#{conn_db}"
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(
@@ -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,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Data
5
- VERSION = '1.10.7'
5
+ VERSION = '1.10.9'
6
6
  end
7
7
  end
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.7
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