legion-data 1.5.3 → 1.6.0

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: 827684e2e0f37c3fb4921f722f6a1053f0585384cad6e3cf89cf6d75c78329f3
4
- data.tar.gz: eb9436c4df397ed39e66e4e4cb24d26afc14f0f8e33153d1381c385eddba7a7a
3
+ metadata.gz: 7d7162dcd4c845698a6c47a4a39f5c70b9da51a5bac097ec9f631934d31716ac
4
+ data.tar.gz: 13af3da2d55afaec4c54ad77ba97d23d7ebaa35789b22bbb9f6cbe1049751191
5
5
  SHA512:
6
- metadata.gz: d365a0142a850ca4793e684932dcb9dd7321d8518533032c98738d4af155be69202339814a3a3ece4abc309f29411dfe59417aa403f55fa03645dcf84674e5c8
7
- data.tar.gz: f26235a9b780c805c49119f7602b79a4b7d0d5e2f059403051455b9e5f47d4b6e16e7772b11d197b154f064db4d1bb88332e83cd91e944bff5b47a90f92ab0ba
6
+ metadata.gz: 8a67d01180e7ec8df32339a2e7bfe3a28f972176d2ac921fbaebc78c3194d098e9d11ddc01fb84db65cda53aaa42ee488c928d68583cecacdefe78eefbe313ba
7
+ data.tar.gz: 7d4f7e00c415d7af0975d575617221d0c010fb3cff4d4c1fae7c4562ee817fd135236ba7b466048fddd8b1c5d06eb84029a3da61c6f900055fab225861efe453
@@ -0,0 +1,7 @@
1
+ # Auto-generated from team-config.yml
2
+ # Team: core
3
+ #
4
+ # To apply: scripts/apply-codeowners.sh legion-data
5
+
6
+ * @LegionIO/maintainers
7
+ * @LegionIO/core
@@ -0,0 +1,18 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ day: monday
8
+ open-pull-requests-limit: 5
9
+ labels:
10
+ - "type:dependencies"
11
+ - package-ecosystem: github-actions
12
+ directory: /
13
+ schedule:
14
+ interval: weekly
15
+ day: monday
16
+ open-pull-requests-limit: 5
17
+ labels:
18
+ - "type:dependencies"
@@ -3,14 +3,32 @@ on:
3
3
  push:
4
4
  branches: [main]
5
5
  pull_request:
6
+ schedule:
7
+ - cron: '0 9 * * 1'
6
8
 
7
9
  jobs:
8
10
  ci:
9
11
  uses: LegionIO/.github/.github/workflows/ci.yml@main
10
12
 
13
+ lint:
14
+ uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main
15
+
16
+ security:
17
+ uses: LegionIO/.github/.github/workflows/security-scan.yml@main
18
+
19
+ version-changelog:
20
+ uses: LegionIO/.github/.github/workflows/version-changelog.yml@main
21
+
22
+ dependency-review:
23
+ uses: LegionIO/.github/.github/workflows/dependency-review.yml@main
24
+
25
+ stale:
26
+ if: github.event_name == 'schedule'
27
+ uses: LegionIO/.github/.github/workflows/stale.yml@main
28
+
11
29
  release:
12
- needs: ci
30
+ needs: [ci, lint]
13
31
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
14
32
  uses: LegionIO/.github/.github/workflows/release.yml@main
15
33
  secrets:
16
- rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
34
+ rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
data/.rubocop.yml CHANGED
@@ -55,3 +55,7 @@ Naming/VariableNumber:
55
55
 
56
56
  Metrics/ParameterLists:
57
57
  Max: 8
58
+
59
+ Style/FileOpen:
60
+ Exclude:
61
+ - 'lib/legion/data/connection.rb'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Legion::Data Changelog
2
2
 
3
+ ## [1.6.0] - 2026-03-25
4
+
5
+ ### Fixed
6
+ - **Connection pool starvation**: `max_connections`, `pool_timeout`, `preconnect`, and all other Sequel options were never forwarded to `Sequel.connect` — pool was stuck at Sequel's default of 4 connections regardless of settings. 5+ second "slow queries" in daemon logs were actually pool wait time (5s `pool_timeout`) + fast query (~19ms). Now all configured options flow through properly.
7
+ - **Local DB had same issue**: `Legion::Data::Local.setup` used bare `Sequel.sqlite(path)` with no options. Now forwards SQLite adapter options (`timeout`, `readonly`, `disable_dqs`) via `Sequel.connect`.
8
+
9
+ ### Changed
10
+ - **Flat settings structure**: all connection settings now live directly on `data.*` instead of nested `data.connection.*` or `data.adapter_opts.*`. Users configure `data.max_connections`, `data.pool_timeout`, `data.connect_timeout`, etc. regardless of adapter — legion-data figures out which options apply.
11
+ - Default `max_connections` raised from 10 to 25 (was never applied before anyway)
12
+ - Default `preconnect` set to `'concurrently'` (warm pool at boot)
13
+ - Default `pool_timeout` remains 5s (now actually enforced)
14
+ - Per-adapter defaults applied at connection time via `ADAPTER_DEFAULTS`: sqlite (`timeout: 5000`, `readonly: false`, `disable_dqs: true`), postgres (`connect_timeout: 20`, `sslmode: 'disable'`), mysql2 (`connect_timeout: 120`, `encoding: 'utf8mb4'`)
15
+ - Adapter-specific settings (`connect_timeout`, `read_timeout`, `write_timeout`, `encoding`, `sql_mode`, `sslmode`, `sslrootcert`, `search_path`, `timeout`, `readonly`, `disable_dqs`) default to nil in settings and resolve to adapter built-in defaults — only forwarded when the current adapter supports them
16
+
17
+ ### Added
18
+ - `GENERIC_KEYS`, `ADAPTER_KEYS`, `ADAPTER_DEFAULTS` constants on `Connection` for option whitelisting and defaults
19
+ - Connection health extensions (non-SQLite only): `connection_validator` (pings idle connections, default timeout 600s) and `connection_expiration` (retires old connections, default timeout 14400s) — both enabled by default via `data.connection_validation` and `data.connection_expiration`
20
+ - `Legion::Data::Connection.stats` — comprehensive connection metrics: pool stats (type, size, available, in_use, waiting), tuning snapshot, and adapter-specific database stats (postgres: `pg_stat_activity`, `pg_database_size`, server settings; sqlite: PRAGMAs, file size; mysql: `information_schema`, `SHOW STATUS`)
21
+ - `Legion::Data::Connection.pool_stats` — works across all Sequel pool types (`timed_queue`, `threaded`, `single`, sharded variants)
22
+ - `Legion::Data::Local.stats` — local SQLite metrics: PRAGMAs, file size, database size, registered migrations
23
+ - `Legion::Data.stats` — combined `{ shared: Connection.stats, local: Local.stats }` for `/api/stats` endpoint
24
+ - `data.query_log` flag (default `false`): when enabled, pipes ALL SQL queries to `~/.legionio/logs/data-shared-query.log` (shared) or `data-local-query.log` (local) via dedicated `QueryFileLogger` — isolated from the main `Legion::Logging` domain so debug query floods don't pollute application logs
25
+ - `Legion::Data::Connection::QueryFileLogger` — thread-safe file-based logger with timestamped entries, used by both shared and local query log modes
26
+ - `Legion::Data::Connection::SlowQueryLogger` — wraps tagged `Legion::Logging::Logger`, prefixes warn-level messages with `[slow-query]`
27
+ - `data.local.query_log` flag (default `false`): same as above but for the local SQLite connection
28
+ - **StaticCache infrastructure** for lookup models: `Legion::Data.setup_static_cache` applies `Sequel::Plugins::StaticCache` to `Extension`, `Runner`, `Function` — loads entire tables into frozen in-memory hashes for zero-DB-hit reads. Enabled via `data.cache.static_cache: true` (default `false`).
29
+ - `Legion::Data.reload_static_cache` — refreshes in-memory static cache after hot-loading new extensions
30
+ - **External cache infrastructure**: `Legion::Data.setup_external_cache` applies `Sequel::Plugins::Caching` to `Relationship` (ttl 10s), `Node` (ttl 10s), `Setting` (ttl configurable) via `Legion::Cache` backend. Activates when `data.cache.auto_enable` is true and `Legion::Cache` is loaded.
31
+ - `data.cache.static_cache` setting (default `false`)
32
+
3
33
  ## [1.5.3] - 2026-03-25
4
34
 
5
35
  ### Added
data/CLAUDE.md CHANGED
@@ -8,7 +8,7 @@
8
8
  Manages persistent database storage for the LegionIO framework. Supports SQLite (default), MySQL, and PostgreSQL via Sequel ORM. Provides automatic schema migrations and data models for extensions, functions, runners, nodes, tasks, settings, digital workers, task relationships, Apollo shared knowledge tables (PostgreSQL only), tenants, webhooks, audit log, and archive tables. Also provides a parallel local SQLite database (`Legion::Data::Local`) for agentic cognitive state persistence.
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/legion-data
11
- **Version**: 1.4.12
11
+ **Version**: 1.6.0
12
12
  **License**: Apache-2.0
13
13
 
14
14
  ## Supported Databases
@@ -28,13 +28,22 @@ Legion::Data (singleton module)
28
28
  ├── .setup # Connect, migrate, load models, setup cache, setup local
29
29
  ├── .connection # Sequel database handle (shared/central)
30
30
  ├── .local # Legion::Data::Local accessor
31
+ ├── .stats # Combined { shared: Connection.stats, local: Local.stats }
32
+ ├── .reload_static_cache # Refresh in-memory StaticCache after hot-loading extensions
31
33
  ├── .shutdown # Close both connections
32
34
 
33
35
  ├── Connection # Sequel database connection management (shared)
34
36
  │ ├── .adapter # Reads from settings (sqlite, mysql2, postgres)
35
37
  │ ├── .setup # Establish connection (dev_mode fallback to SQLite if network DB unreachable)
36
38
  │ ├── .sequel # Raw Sequel::Database accessor
37
- └── .shutdown # Close connection
39
+ ├── .stats # Pool metrics, tuning snapshot, adapter-specific DB stats
40
+ │ ├── .pool_stats # Connection pool usage (size, available, in_use, waiting)
41
+ │ ├── .shutdown # Close connection
42
+ │ ├── GENERIC_KEYS # Pool options forwarded to Sequel (:max_connections, :pool_timeout, etc.)
43
+ │ ├── ADAPTER_KEYS # Per-adapter option whitelists (sqlite, postgres, mysql2)
44
+ │ ├── ADAPTER_DEFAULTS # Built-in defaults per adapter when user hasn't set a value
45
+ │ ├── SlowQueryLogger # Wraps Legion::Logging with [slow-query] prefix for Sequel warn
46
+ │ └── QueryFileLogger # Thread-safe file logger for query_log mode (~/.legionio/logs/)
38
47
 
39
48
  ├── Local # Local SQLite database for agentic cognitive state
40
49
  │ ├── .setup # Lazy init — creates legionio_local.db on first access
@@ -43,6 +52,7 @@ Legion::Data (singleton module)
43
52
  │ ├── .db_path # Path to the local SQLite file
44
53
  │ ├── .model(:table) # Create Sequel::Model bound to local connection
45
54
  │ ├── .register_migrations(name:, path:) # Extensions register their migration dirs
55
+ │ ├── .stats # Local SQLite metrics (PRAGMAs, file size, registered migrations)
46
56
  │ ├── .shutdown # Close local connection
47
57
  │ └── .reset! # Clear all state (testing)
48
58
 
@@ -105,12 +115,16 @@ Legion::Data (singleton module)
105
115
  ### Key Design Patterns
106
116
 
107
117
  - **Two-Database Architecture**: Shared (MySQL/PG/SQLite) for control plane data + Local (always SQLite) for agentic cognitive state. Two files, always separate, no cross-database joins.
108
- - **Adapter-Driven**: `Connection.adapter` reads from settings; SQLite uses `Sequel.sqlite(path)`, others use `Sequel.connect`
118
+ - **Adapter-Driven**: `Connection.adapter` reads from settings; all adapters (including SQLite) use `Sequel.connect` so all options flow through uniformly
119
+ - **Flat Settings**: all connection/pool/adapter options live directly on `data.*` — legion-data resolves which options apply to the current adapter via `ADAPTER_KEYS` whitelists
120
+ - **Per-Adapter Defaults**: `ADAPTER_DEFAULTS` provides built-in defaults (e.g., sqlite timeout 5000, postgres connect_timeout 20) when user hasn't set a value; nil in settings means "use adapter default"
109
121
  - **Dev Mode Fallback**: When `dev_mode: true` and network DB unreachable, shared connection falls back to SQLite (`legionio.db`) with warning log
122
+ - **Connection Health**: `connection_validator` (pings idle connections) and `connection_expiration` (retires old connections) extensions auto-enabled for non-SQLite adapters
110
123
  - **Cross-DB Migrations**: Shared migrations use IntegerMigrator (Sequel DSL), local migrations use TimestampMigrator (per-extension registration)
111
124
  - **Auto-Migration**: Runs Sequel migrations on startup (`auto_migrate: true` by default)
112
125
  - **Sequel ORM**: Shared models are `Sequel::Model` subclasses (inherit global connection). Local models use `Legion::Data::Local.model(:table)` (explicit connection binding).
113
- - **Optional Caching**: `setup_cache` checks for `Legion::Cache` presence but Sequel model caching is currently disabled (code is commented out, pending implementation)
126
+ - **Two-Tier Caching**: StaticCache (in-process frozen hash, no external deps) for lookup models (Extension, Runner, Function) + external Caching plugin (via `Legion::Cache` Redis/Memcached/Memory) for dynamic models (Relationship, Node, Setting). Both disabled by default.
127
+ - **Query Log Isolation**: `query_log` flag pipes all SQL to dedicated files (`~/.legionio/logs/data-shared-query.log`, `data-local-query.log`) via `QueryFileLogger` — completely isolated from the `Legion::Logging` domain
114
128
  - **Cryptographic Erasure**: Deleting `legionio_local.db` is a hard guarantee — no residual data. Used by `lex-privatecore`.
115
129
  - **CLI Executable**: Ships with `legionio_migrate` executable in `exe/` for running database migrations standalone
116
130
 
@@ -123,14 +137,40 @@ Legion::Data (singleton module)
123
137
  "dev_mode": false,
124
138
  "dev_fallback": true,
125
139
  "connect_on_start": true,
126
- "connection": {
127
- "log": false,
128
- "log_connection_info": false,
129
- "log_warn_duration": 1,
130
- "sql_log_level": "debug",
131
- "max_connections": 10,
132
- "preconnect": false
133
- },
140
+
141
+ "max_connections": 25,
142
+ "pool_timeout": 5,
143
+ "preconnect": "concurrently",
144
+ "single_threaded": false,
145
+ "test": true,
146
+ "name": null,
147
+
148
+ "log": false,
149
+ "query_log": false,
150
+ "log_connection_info": false,
151
+ "log_warn_duration": 1,
152
+ "sql_log_level": "debug",
153
+
154
+ "connection_validation": true,
155
+ "connection_validation_timeout": 600,
156
+ "connection_expiration": true,
157
+ "connection_expiration_timeout": 14400,
158
+
159
+ "connect_timeout": null,
160
+ "read_timeout": null,
161
+ "write_timeout": null,
162
+ "encoding": null,
163
+ "sql_mode": null,
164
+ "sslmode": null,
165
+ "sslrootcert": null,
166
+ "search_path": null,
167
+ "timeout": null,
168
+ "readonly": null,
169
+ "disable_dqs": null,
170
+
171
+ "read_replica_url": null,
172
+ "replicas": [],
173
+
134
174
  "creds": {
135
175
  "database": "legionio.db"
136
176
  },
@@ -147,6 +187,7 @@ Legion::Data (singleton module)
147
187
  "local": {
148
188
  "enabled": true,
149
189
  "database": "legionio_local.db",
190
+ "query_log": false,
150
191
  "migrations": {
151
192
  "auto_migrate": true
152
193
  }
@@ -154,11 +195,36 @@ Legion::Data (singleton module)
154
195
  "cache": {
155
196
  "connected": false,
156
197
  "auto_enable": false,
198
+ "static_cache": false,
157
199
  "ttl": 60
200
+ },
201
+ "archival": {
202
+ "retention_days": 90,
203
+ "batch_size": 1000,
204
+ "storage_backend": null
158
205
  }
159
206
  }
160
207
  ```
161
208
 
209
+ Settings are **flat** — all pool, logging, health, and adapter-specific options live directly on `data.*`. Adapter-specific options (e.g., `connect_timeout`, `encoding`, `sslmode`) default to `null` and resolve to per-adapter built-in defaults at connection time:
210
+
211
+ | Adapter | Applied Options | Defaults |
212
+ |---------|----------------|----------|
213
+ | sqlite | `timeout`, `readonly`, `disable_dqs` | `timeout: 5000`, `readonly: false`, `disable_dqs: true` |
214
+ | postgres | `connect_timeout`, `sslmode`, `sslrootcert`, `search_path` | `connect_timeout: 20`, `sslmode: "disable"` |
215
+ | mysql2 | `connect_timeout`, `read_timeout`, `write_timeout`, `encoding`, `sql_mode` | `connect_timeout: 120`, `encoding: "utf8mb4"` |
216
+
217
+ ### Caching
218
+
219
+ Two independent caching tiers, both disabled by default:
220
+
221
+ | Tier | Setting | Models | Backend | Use Case |
222
+ |------|---------|--------|---------|----------|
223
+ | **StaticCache** | `data.cache.static_cache: true` | Extension, Runner, Function | In-process frozen Ruby hash | Zero-DB-hit reads for lookup tables. No external deps. Call `Legion::Data.reload_static_cache` after hot-loading extensions. |
224
+ | **External Cache** | `data.cache.auto_enable: true` + `Legion::Cache` loaded | Relationship (10s), Node (10s), Setting (ttl) | `Legion::Cache` (Redis/Memcached/Memory) | Cross-process cache sharing for dynamic models. Requires `legion-cache` gem connected. |
225
+
226
+ For thousands of agents, enable `static_cache` first — biggest impact, zero dependencies. External cache only adds value when you need cross-process sharing via Redis/Memcached.
227
+
162
228
  Per-adapter credential defaults are defined in `Settings::CREDS`:
163
229
  - **sqlite**: `{ database: "legionio.db" }`
164
230
  - **mysql2**: `{ username: "legion", password: "legion", database: "legionio", host: "127.0.0.1", port: 3306 }`
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'sequel'
4
5
 
5
6
  module Legion
@@ -7,6 +8,22 @@ module Legion
7
8
  module Connection
8
9
  ADAPTERS = %i[sqlite mysql2 postgres].freeze
9
10
 
11
+ GENERIC_KEYS = %i[max_connections pool_timeout preconnect single_threaded test name].freeze
12
+
13
+ ADAPTER_KEYS = {
14
+ sqlite: %i[timeout readonly disable_dqs],
15
+ postgres: %i[connect_timeout sslmode sslrootcert search_path],
16
+ mysql2: %i[connect_timeout read_timeout write_timeout encoding sql_mode]
17
+ }.freeze
18
+
19
+ ADAPTER_DEFAULTS = {
20
+ sqlite: { timeout: 5000, readonly: false, disable_dqs: true },
21
+ postgres: { connect_timeout: 20, sslmode: 'disable' },
22
+ mysql2: { connect_timeout: 120, encoding: 'utf8mb4' }
23
+ }.freeze
24
+
25
+ QUERY_LOG_DIR = File.expand_path('~/.legionio/logs').freeze
26
+
10
27
  # Wraps a tagged Legion::Logging::Logger for Sequel's logger interface.
11
28
  # Prefixes warn-level messages with [slow-query] since Sequel uses warn
12
29
  # for queries exceeding log_warn_duration.
@@ -32,6 +49,52 @@ module Legion
32
49
  end
33
50
  end
34
51
 
52
+ # File-based query logger that writes all SQL to a dedicated log file.
53
+ # Isolated from the main Legion::Logging domain.
54
+ class QueryFileLogger
55
+ attr_reader :path
56
+
57
+ def initialize(path)
58
+ @path = path
59
+ dir = File.dirname(path)
60
+ FileUtils.mkdir_p(dir)
61
+ FileUtils.chmod(0o700, dir) if File.directory?(dir)
62
+ @file = File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o600)
63
+ @file.sync = true
64
+ @mutex = Mutex.new
65
+ end
66
+
67
+ def debug(message)
68
+ write('DEBUG', message)
69
+ end
70
+
71
+ def info(message)
72
+ write('INFO', message)
73
+ end
74
+
75
+ def warn(message)
76
+ write('WARN', message)
77
+ end
78
+
79
+ def error(message)
80
+ write('ERROR', message)
81
+ end
82
+
83
+ def close
84
+ @mutex.synchronize { @file.close unless @file.closed? }
85
+ end
86
+
87
+ private
88
+
89
+ def write(level, message)
90
+ @mutex.synchronize do
91
+ @file.puts "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')}] #{level} #{message}"
92
+ end
93
+ rescue IOError
94
+ nil
95
+ end
96
+ end
97
+
35
98
  class << self
36
99
  attr_accessor :sequel
37
100
 
@@ -40,11 +103,12 @@ module Legion
40
103
  end
41
104
 
42
105
  def setup
106
+ opts = sequel_opts
43
107
  @sequel = if adapter == :sqlite
44
- ::Sequel.sqlite(sqlite_path)
108
+ ::Sequel.connect(opts.merge(adapter: :sqlite, database: sqlite_path))
45
109
  else
46
110
  begin
47
- ::Sequel.connect(adapter: adapter, **creds_builder)
111
+ ::Sequel.connect(opts.merge(adapter: adapter, **creds_builder))
48
112
  rescue StandardError => e
49
113
  raise unless dev_fallback?
50
114
 
@@ -54,7 +118,8 @@ module Legion
54
118
  )
55
119
  end
56
120
  @adapter = :sqlite
57
- ::Sequel.sqlite(sqlite_path)
121
+ sqlite_opts = sequel_opts
122
+ ::Sequel.connect(sqlite_opts.merge(adapter: :sqlite, database: sqlite_path))
58
123
  end
59
124
  end
60
125
  Legion::Settings[:data][:connected] = true
@@ -70,12 +135,61 @@ module Legion
70
135
  Legion::Logging.info "Connected to #{adapter}://#{user}@#{host}:#{port}/#{db}"
71
136
  end
72
137
  end
73
- configure_logging
138
+ configure_extensions
74
139
  connect_with_replicas
75
140
  end
76
141
 
142
+ def stats
143
+ return { connected: false } unless @sequel
144
+
145
+ data = Legion::Settings[:data]
146
+ {
147
+ connected: data[:connected],
148
+ adapter: adapter,
149
+ pool: pool_stats,
150
+ tuning: tuning_stats(data),
151
+ database: database_stats
152
+ }
153
+ rescue StandardError => e
154
+ { connected: (data[:connected] if data.is_a?(Hash)), adapter: adapter, error: e.message }
155
+ end
156
+
157
+ def pool_stats
158
+ return {} unless @sequel
159
+
160
+ pool = @sequel.pool
161
+ stats = {
162
+ type: pool.pool_type,
163
+ size: pool.size,
164
+ max_size: pool.respond_to?(:max_size) ? pool.max_size : nil
165
+ }
166
+
167
+ case pool.pool_type
168
+ when :timed_queue, :sharded_timed_queue
169
+ queue_size = pool.instance_variable_get(:@queue)&.size || 0
170
+ stats[:available] = queue_size
171
+ stats[:in_use] = stats[:size] - queue_size
172
+ stats[:waiting] = pool.num_waiting
173
+ when :threaded, :sharded_threaded
174
+ avail = pool.instance_variable_get(:@available_connections)
175
+ stats[:available] = avail&.size || 0
176
+ stats[:in_use] = stats[:size] - stats[:available]
177
+ stats[:waiting] = pool.num_waiting
178
+ when :single, :sharded_single
179
+ stats[:available] = pool.size
180
+ stats[:in_use] = 0
181
+ stats[:waiting] = 0
182
+ end
183
+
184
+ stats.compact
185
+ rescue StandardError
186
+ {}
187
+ end
188
+
77
189
  def shutdown
78
190
  @sequel&.disconnect
191
+ @query_file_logger&.close
192
+ @query_file_logger = nil
79
193
  Legion::Settings[:data][:connected] = false
80
194
  Legion::Logging.info 'Legion::Data connection closed' if defined?(Legion::Logging)
81
195
  end
@@ -176,12 +290,175 @@ module Legion
176
290
  Legion::Settings[:data][:creds][:database] || 'legionio.db'
177
291
  end
178
292
 
179
- def configure_logging
180
- return if Legion::Settings[:data][:connection].nil? || Legion::Settings[:data][:connection][:log].nil?
293
+ def sequel_opts
294
+ data = Legion::Settings[:data]
295
+ opts = {}
181
296
 
182
- @sequel.logger = build_data_logger
183
- @sequel.sql_log_level = Legion::Settings[:data][:connection][:sql_log_level]
184
- @sequel.log_warn_duration = Legion::Settings[:data][:connection][:log_warn_duration]
297
+ # Generic pool options
298
+ GENERIC_KEYS.each do |key|
299
+ val = data[key]
300
+ opts[key] = val unless val.nil?
301
+ end
302
+
303
+ # Query log mode: all queries to dedicated file, isolated from main domain
304
+ if data[:query_log]
305
+ log_path = File.join(QUERY_LOG_DIR, 'data-shared-query.log')
306
+ @query_file_logger = QueryFileLogger.new(log_path)
307
+ opts[:logger] = @query_file_logger
308
+ opts[:sql_log_level] = :debug
309
+ opts[:log_connection_info] = data[:log_connection_info] || false
310
+ elsif data[:log] && defined?(Legion::Logging)
311
+ # Standard mode: slow-query warnings through Legion::Logging domain
312
+ opts[:logger] = build_data_logger
313
+ opts[:sql_log_level] = data[:sql_log_level]&.to_sym || :debug
314
+ opts[:log_warn_duration] = data[:log_warn_duration]
315
+ opts[:log_connection_info] = data[:log_connection_info] || false
316
+ end
317
+
318
+ # Adapter-specific: user setting wins, then built-in default, skip if nil
319
+ defaults = ADAPTER_DEFAULTS.fetch(adapter, {})
320
+ ADAPTER_KEYS.fetch(adapter, []).each do |key|
321
+ val = data.key?(key) && !data[key].nil? ? data[key] : defaults[key]
322
+ opts[key] = val unless val.nil?
323
+ end
324
+
325
+ opts
326
+ end
327
+
328
+ def tuning_stats(data)
329
+ tuning = {}
330
+
331
+ # Pool tuning
332
+ GENERIC_KEYS.each { |key| tuning[key] = data[key] }
333
+
334
+ # Logging
335
+ tuning[:log] = data[:log]
336
+ tuning[:query_log] = data[:query_log]
337
+ tuning[:query_log_path] = @query_file_logger&.path
338
+ tuning[:log_warn_duration] = data[:log_warn_duration]
339
+ tuning[:sql_log_level] = data[:sql_log_level]
340
+ tuning[:log_connection_info] = data[:log_connection_info]
341
+
342
+ # Connection health
343
+ tuning[:connection_validation] = data[:connection_validation]
344
+ tuning[:connection_validation_timeout] = data[:connection_validation_timeout]
345
+ tuning[:connection_expiration] = data[:connection_expiration]
346
+ tuning[:connection_expiration_timeout] = data[:connection_expiration_timeout]
347
+
348
+ # Adapter-specific (only keys relevant to current adapter)
349
+ defaults = ADAPTER_DEFAULTS.fetch(adapter, {})
350
+ ADAPTER_KEYS.fetch(adapter, []).each do |key|
351
+ tuning[key] = data.key?(key) && !data[key].nil? ? data[key] : defaults[key]
352
+ end
353
+
354
+ tuning
355
+ end
356
+
357
+ def database_stats
358
+ case adapter
359
+ when :sqlite then sqlite_stats
360
+ when :postgres then postgres_stats
361
+ when :mysql2 then mysql_stats
362
+ else {}
363
+ end
364
+ rescue StandardError => e
365
+ { error: e.message }
366
+ end
367
+
368
+ def sqlite_stats
369
+ db = @sequel
370
+ stats = {}
371
+ %w[page_size page_count freelist_count journal_mode wal_autocheckpoint
372
+ cache_size busy_timeout].each do |pragma|
373
+ val = begin
374
+ db.fetch("PRAGMA #{pragma}").single_value
375
+ rescue StandardError
376
+ nil
377
+ end
378
+ stats[pragma.to_sym] = val unless val.nil?
379
+ end
380
+
381
+ db_path = Legion::Settings[:data][:creds][:database] || 'legionio.db'
382
+ stats[:file_size] = File.size(db_path) if File.exist?(db_path)
383
+ stats[:database_size_bytes] = (stats[:page_size].to_i * stats[:page_count].to_i) if stats[:page_size] && stats[:page_count]
384
+ stats
385
+ end
386
+
387
+ def postgres_stats
388
+ db = @sequel
389
+ stats = {}
390
+
391
+ row = db.fetch('SELECT current_database() AS db, pg_database_size(current_database()) AS size_bytes').first
392
+ stats[:database_name] = row[:db]
393
+ stats[:database_size_bytes] = row[:size_bytes]
394
+
395
+ activity = db.fetch(<<~SQL).first
396
+ SELECT
397
+ count(*) FILTER (WHERE state = 'active') AS active,
398
+ count(*) FILTER (WHERE state = 'idle') AS idle,
399
+ count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
400
+ count(*) AS total
401
+ FROM pg_stat_activity
402
+ WHERE datname = current_database()
403
+ SQL
404
+ stats[:server_connections] = activity
405
+
406
+ settings = db.fetch(<<~SQL).first
407
+ SELECT
408
+ current_setting('max_connections')::int AS max_connections,
409
+ current_setting('shared_buffers') AS shared_buffers,
410
+ current_setting('work_mem') AS work_mem,
411
+ current_setting('server_version') AS server_version
412
+ SQL
413
+ stats[:server] = settings
414
+
415
+ stats
416
+ end
417
+
418
+ def mysql_stats
419
+ db = @sequel
420
+ stats = {}
421
+
422
+ size_row = db.fetch(<<~SQL).first
423
+ SELECT SUM(data_length + index_length) AS size_bytes
424
+ FROM information_schema.tables
425
+ WHERE table_schema = DATABASE()
426
+ SQL
427
+ stats[:database_name] = db.fetch('SELECT DATABASE() AS db').single_value
428
+ stats[:database_size_bytes] = size_row[:size_bytes]&.to_i
429
+
430
+ threads = {}
431
+ db.fetch("SHOW STATUS WHERE Variable_name IN ('Threads_connected','Threads_running','Max_used_connections')").each do |row|
432
+ threads[row[:Variable_name].downcase.to_sym] = row[:Value].to_i
433
+ end
434
+ stats[:server_connections] = threads
435
+
436
+ max_conn = db.fetch("SHOW VARIABLES LIKE 'max_connections'").first
437
+ version = db.fetch('SELECT VERSION() AS v').single_value
438
+ stats[:server] = {
439
+ max_connections: max_conn ? max_conn[:Value].to_i : nil,
440
+ server_version: version
441
+ }
442
+
443
+ stats
444
+ end
445
+
446
+ def configure_extensions
447
+ return if adapter == :sqlite
448
+
449
+ data = Legion::Settings[:data]
450
+
451
+ if data[:connection_validation] != false
452
+ @sequel.extension(:connection_validator)
453
+ @sequel.pool.connection_validation_timeout = data[:connection_validation_timeout] || 600
454
+ end
455
+
456
+ if data[:connection_expiration] != false
457
+ @sequel.extension(:connection_expiration)
458
+ @sequel.pool.connection_expiration_timeout = data[:connection_expiration_timeout] || 14_400
459
+ end
460
+ rescue StandardError => e
461
+ Legion::Logging.warn "Failed to load connection extensions: #{e.message}" if defined?(Legion::Logging)
185
462
  end
186
463
 
187
464
  def build_data_logger
@@ -14,7 +14,23 @@ module Legion
14
14
 
15
15
  db_file = database || local_settings[:database] || 'legionio_local.db'
16
16
  @db_path = db_file
17
- @connection = ::Sequel.sqlite(db_file)
17
+
18
+ sqlite_defaults = Legion::Data::Connection::ADAPTER_DEFAULTS.fetch(:sqlite, {})
19
+ data = defined?(Legion::Settings) ? Legion::Settings[:data] : {}
20
+ opts = { adapter: :sqlite, database: db_file }
21
+ Legion::Data::Connection::ADAPTER_KEYS.fetch(:sqlite, []).each do |key|
22
+ val = data.key?(key) && !data[key].nil? ? data[key] : sqlite_defaults[key]
23
+ opts[key] = val unless val.nil?
24
+ end
25
+
26
+ if local_settings[:query_log]
27
+ log_path = File.join(Legion::Data::Connection::QUERY_LOG_DIR, 'data-local-query.log')
28
+ @query_file_logger = Legion::Data::Connection::QueryFileLogger.new(log_path)
29
+ opts[:logger] = @query_file_logger
30
+ opts[:sql_log_level] = :debug
31
+ end
32
+
33
+ @connection = ::Sequel.connect(opts)
18
34
  @connected = true
19
35
  run_migrations
20
36
  Legion::Logging.info "Legion::Data::Local connected to #{db_file}" if defined?(Legion::Logging)
@@ -22,6 +38,8 @@ module Legion
22
38
 
23
39
  def shutdown
24
40
  @connection&.disconnect
41
+ @query_file_logger&.close
42
+ @query_file_logger = nil
25
43
  @connection = nil
26
44
  @connected = false
27
45
  end
@@ -46,6 +64,37 @@ module Legion
46
64
  ::Sequel::Model(connection[table_name])
47
65
  end
48
66
 
67
+ def stats
68
+ return { connected: false } unless connected?
69
+
70
+ stats = {
71
+ connected: true,
72
+ adapter: :sqlite,
73
+ path: @db_path,
74
+ query_log: local_settings[:query_log] || false,
75
+ query_log_path: @query_file_logger&.path,
76
+ registered_migrations: registered_migrations.keys
77
+ }
78
+
79
+ stats[:file_size] = File.size(@db_path) if @db_path && File.exist?(@db_path)
80
+
81
+ %w[page_size page_count freelist_count journal_mode
82
+ wal_autocheckpoint cache_size busy_timeout].each do |pragma|
83
+ val = begin
84
+ @connection.fetch("PRAGMA #{pragma}").single_value
85
+ rescue StandardError
86
+ nil
87
+ end
88
+ stats[pragma.to_sym] = val unless val.nil?
89
+ end
90
+
91
+ stats[:database_size_bytes] = stats[:page_size].to_i * stats[:page_count].to_i if stats[:page_size] && stats[:page_count]
92
+
93
+ stats
94
+ rescue StandardError => e
95
+ { connected: connected?, error: e.message }
96
+ end
97
+
49
98
  def reset!
50
99
  @connection = nil
51
100
  @connected = false
@@ -25,20 +25,55 @@ module Legion
25
25
 
26
26
  def self.default
27
27
  {
28
- adapter: 'sqlite',
29
- connected: false,
30
- cache: cache,
31
- connection: connection,
32
- creds: creds,
33
- migrations: migrations,
34
- models: models,
35
- local: local,
36
- dev_mode: false,
37
- dev_fallback: true,
38
- connect_on_start: true,
39
- read_replica_url: nil,
40
- replicas: [],
41
- archival: archival
28
+ adapter: 'sqlite',
29
+ connected: false,
30
+
31
+ # Connection pool
32
+ max_connections: 25,
33
+ pool_timeout: 5,
34
+ preconnect: 'concurrently',
35
+ single_threaded: false,
36
+ test: true,
37
+ name: nil,
38
+
39
+ # Logging
40
+ log: false,
41
+ query_log: false,
42
+ log_connection_info: false,
43
+ log_warn_duration: 1,
44
+ sql_log_level: 'debug',
45
+
46
+ # Connection health (network adapters only, ignored for sqlite)
47
+ connection_validation: true,
48
+ connection_validation_timeout: 600,
49
+ connection_expiration: true,
50
+ connection_expiration_timeout: 14_400,
51
+
52
+ # Adapter-specific (nil = use adapter built-in default)
53
+ connect_timeout: nil,
54
+ read_timeout: nil,
55
+ write_timeout: nil,
56
+ encoding: nil,
57
+ sql_mode: nil,
58
+ sslmode: nil,
59
+ sslrootcert: nil,
60
+ search_path: nil,
61
+ timeout: nil,
62
+ readonly: nil,
63
+ disable_dqs: nil,
64
+
65
+ # Grouped settings
66
+ creds: creds,
67
+ cache: cache,
68
+ migrations: migrations,
69
+ models: models,
70
+ local: local,
71
+ dev_mode: false,
72
+ dev_fallback: true,
73
+ connect_on_start: true,
74
+ read_replica_url: nil,
75
+ replicas: [],
76
+ archival: archival
42
77
  }
43
78
  end
44
79
 
@@ -46,6 +81,7 @@ module Legion
46
81
  {
47
82
  enabled: true,
48
83
  database: 'legionio_local.db',
84
+ query_log: false,
49
85
  migrations: { auto_migrate: true }
50
86
  }
51
87
  end
@@ -66,17 +102,6 @@ module Legion
66
102
  }
67
103
  end
68
104
 
69
- def self.connection
70
- {
71
- log: false,
72
- log_connection_info: false,
73
- log_warn_duration: 1,
74
- sql_log_level: 'debug',
75
- max_connections: 10,
76
- preconnect: false
77
- }
78
- end
79
-
80
105
  def self.creds(adapter = nil)
81
106
  adapter = (adapter || :sqlite).to_sym
82
107
  CREDS.fetch(adapter, CREDS[:sqlite]).dup
@@ -92,9 +117,10 @@ module Legion
92
117
 
93
118
  def self.cache
94
119
  {
95
- connected: false,
96
- auto_enable: Legion::Settings[:cache][:connected],
97
- ttl: 60
120
+ connected: false,
121
+ auto_enable: Legion::Settings[:cache][:connected],
122
+ static_cache: false,
123
+ ttl: 60
98
124
  }
99
125
  end
100
126
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Data
5
- VERSION = '1.5.3'
5
+ VERSION = '1.6.0'
6
6
  end
7
7
  end
data/lib/legion/data.rb CHANGED
@@ -47,22 +47,48 @@ module Legion
47
47
  Legion::Data::Local
48
48
  end
49
49
 
50
+ def stats
51
+ {
52
+ shared: Legion::Data::Connection.stats,
53
+ local: Legion::Data::Local.stats
54
+ }
55
+ end
56
+
50
57
  def setup_cache
51
- return if Legion::Settings[:data][:cache][:enabled]
52
-
53
- nil unless defined?(::Legion::Cache)
54
-
55
- # Legion::Data::Model::Relationship.plugin :caching, Legion::Cache, ttl: 10
56
- # Legion::Data::Model::Runner.plugin :caching, Legion::Cache, ttl: 60
57
- # Legion::Data::Model::Chain.plugin :caching, Legion::Cache, ttl: 60
58
- # Legion::Data::Model::Function.plugin :caching, Legion::Cache, ttl: 120
59
- # Legion::Data::Model::Extension.plugin :caching, Legion::Cache, ttl: 120
60
- # Legion::Data::Model::Node.plugin :caching, Legion::Cache, ttl: 10
61
- # Legion::Data::Model::TaskLog.plugin :caching, Legion::Cache, ttl: 12
62
- # Legion::Data::Model::Task.plugin :caching, Legion::Cache, ttl: 10
63
- # Legion::Data::Model::User.plugin :caching, Legion::Cache, ttl: 120
64
- # Legion::Data::Model::Group.plugin :caching, Legion::Cache, ttl: 120
65
- # Legion::Logging.info 'Legion::Data connected to Legion::Cache'
58
+ cache_settings = Legion::Settings[:data][:cache]
59
+ setup_static_cache if cache_settings[:static_cache]
60
+ setup_external_cache if cache_settings[:auto_enable] && defined?(::Legion::Cache)
61
+ end
62
+
63
+ def setup_static_cache
64
+ [Model::Extension, Model::Runner, Model::Function].each do |model|
65
+ model.plugin :static_cache
66
+ Legion::Logging.debug("StaticCache enabled for #{model}") if defined?(Legion::Logging)
67
+ rescue StandardError => e
68
+ Legion::Logging.warn("StaticCache failed for #{model}: #{e.message}") if defined?(Legion::Logging)
69
+ end
70
+ Legion::Logging.info 'Legion::Data static cache loaded' if defined?(Legion::Logging)
71
+ end
72
+
73
+ def reload_static_cache
74
+ [Model::Extension, Model::Runner, Model::Function].each do |model|
75
+ model.load_cache if model.respond_to?(:load_cache)
76
+ end
77
+ end
78
+
79
+ def setup_external_cache
80
+ ttl = Legion::Settings[:data][:cache][:ttl] || 60
81
+ {
82
+ Model::Relationship => 10,
83
+ Model::Node => 10,
84
+ Model::Setting => ttl
85
+ }.each do |model, model_ttl|
86
+ model.plugin :caching, ::Legion::Cache, ttl: model_ttl
87
+ Legion::Logging.debug("Caching enabled for #{model} (ttl: #{model_ttl})") if defined?(Legion::Logging)
88
+ rescue StandardError => e
89
+ Legion::Logging.warn("Caching failed for #{model}: #{e.message}") if defined?(Legion::Logging)
90
+ end
91
+ Legion::Logging.info 'Legion::Data external cache connected' if defined?(Legion::Logging)
66
92
  end
67
93
 
68
94
  def shutdown
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.5.3
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -75,6 +75,8 @@ extra_rdoc_files:
75
75
  - LICENSE
76
76
  - README.md
77
77
  files:
78
+ - ".github/CODEOWNERS"
79
+ - ".github/dependabot.yml"
78
80
  - ".github/workflows/ci.yml"
79
81
  - ".gitignore"
80
82
  - ".rubocop.yml"