legion-data 1.10.17 → 1.10.28

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: cc2433eff09e371c9b406ba8790fe20da105a2920fb5490947ae590158eee403
4
- data.tar.gz: 6d4ec2038079bbf0f01be66b2093313af264651e69ff7d38bf0a89a1f8656b43
3
+ metadata.gz: ec41889e66777b7940c00db2ed4f19ec704145ea27b149604ba82a168fed9e0b
4
+ data.tar.gz: a1d0eec9f3cd9e84d63a4b1eb4e145330b4cdd6354d742ca6f003ea78558ca4c
5
5
  SHA512:
6
- metadata.gz: fdf39d3c2f11766069af4432713e353e87196478585d1c3fe782f1db0aa298f4c0520b579dadb8a343e2b74afdfba485619c29d23188340b45eab641ea726a73
7
- data.tar.gz: 4a92b1cd8ab39a28fb9b144286602a851a99d64f468678cc704da1fdb79001cfd60354778da7ea41e45c6f300241ae025fe537743c507763b3d2c0967dd8522b
6
+ metadata.gz: b62c13c70c6330f24c17c92c645887f7d506ac83b7978cd33226ca198b0294d49823c0143d778cb3cd03e73e508b87b4b90c1ed229f3fd558e7c5f4b7c9a8710
7
+ data.tar.gz: 3def1ace7b8172f1b8bd3b9ebd513fae7a65bc10cab304a5f6f7a593f6b44ef3431e93484c12af547b34961107fe02901d0930aefa6a8c8405501c95f7bf3de5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Legion::Data Changelog
2
2
 
3
+ ## [1.10.28] - 2026-10-30
4
+
5
+ ### Changed
6
+ - Remove the unmerged task-idempotency claim migration and restore the existing task creation API without database claims. The concurrent-creation fix for #77 is withdrawn from this PR (#84).
7
+ - Renumber the unmerged append-claims migration from 139 to 138, leaving a contiguous migration sequence without adding a compensating migration. Migrations already on main are unchanged.
8
+
9
+ ## [1.10.27] - 2026-10-30
10
+
11
+ ### Fixed
12
+ - Remove unmerged migration 140 rather than conditionally taking ownership of a runtime-created archive. Verification handles hot-only history without creating schema and rechecks for an archive created during the first verification pass (#79, #84).
13
+ - Preserve database errors other than a genuinely missing archive, including permission failures. Existing hot/warm verification remains a single ordered statement; migrations already on main are unchanged.
14
+
15
+ ## [1.10.26] - 2026-10-29
16
+
17
+ ### Fixed
18
+ - Exercise concurrent SQLite audit/event appends with the same cooperative busy handler used by production connections, avoiding native busy-wait starvation in Ruby 3.4 CI while retaining lock-timeout behavior (#78).
19
+ - Initialize concurrency-spec cleanup state before resource setup and guard an absent release queue so early setup failures are not masked by cleanup errors (#84).
20
+
21
+ ## [1.10.25] - 2026-10-29
22
+
23
+ ### Fixed
24
+ - Archive generic retention backlogs in primary-key-ordered, bounded transactions. Copy locked rows and delete only their complete identities; verify deletion counts and roll back inconsistent batches. Independent workers cannot copy the same locked rows, and concurrent archive-table creation handles PostgreSQL catalog races (#82).
25
+ - Add an optional positive `batch_size` (default 1000), require a declared source primary key, and support composite keys without exceeding SQLite predicate-depth limits. Completed batches survive later failures or process interruption; reruns move only remaining eligible records and report their aggregate count.
26
+
27
+ ## [1.10.24] - 2026-10-29
28
+
29
+ ### Fixed
30
+ - Use the active adapter for privilege checks, including SQLite fallback. PostgreSQL uses native table privileges; MySQL uses nonmutating SELECT and EXPLAIN INSERT probes rather than PostgreSQL-only SQL. Failed queries return false without poisoning future checks (#81).
31
+ - Cache successful privilege lookups per physical connection, invalidate on connection replacement and credential rotation, and prevent in-flight old sessions from overwriting replacement permissions.
32
+
33
+ ## [1.10.23] - 2026-10-29
34
+
35
+ ### Fixed
36
+ - Claim spool entries with nonblocking cross-process file locks before reading, replaying, quarantining, or clearing them. Recheck descriptor identity after acquisition so an already-open deleted file cannot replay again. Reads and clears skip in-flight entries (#80).
37
+ - Preserve callback exceptions and pending events for retry; operating-system lock release recovers crashed owners without stale claim files. Delivery remains at least once if a process exits after its side effect but before deleting the event.
38
+
39
+ ## [1.10.22] - 2026-10-29
40
+
41
+ ### Fixed
42
+ - Verify the audit log across retained hot and warm rows in one ordered database statement, preserving genesis, predecessor, content-hash, and duplicate detection after repeated retention boundaries. Explicit record collections remain strictly verified as supplied; verification counts consumed rows without building an additional result array (#79).
43
+
44
+ ## [1.10.21] - 2026-10-29
45
+
46
+ ### Fixed
47
+ - Serialize audit-chain and governance-stream appends with database claims, including first-entry races. Migration 138 adds scoped append claims. MySQL uses exclusive duplicate-key locking rather than shared-lock upgrades (#78).
48
+ - Read locked tails on network adapters, retain database lock-timeout errors, and verify/walk audit records by append ID rather than writer-clock order. Unrelated insert failures roll back their claims and remain visible to callers.
49
+
50
+ ## [1.10.19] - 2026-10-29
51
+
52
+ ### Fixed
53
+ - Drain database pools through tracked public checkout lifetimes across threaded and sharded pool implementations. Idle pools close immediately; shutdown returns false at its deadline while accepted work finishes, rejects new work, and closes connections and query logging only after the last checkout returns (#76).
54
+ - Rebind loaded models and association caches to the current database on setup so a restart does not reopen a closed pool.
55
+
56
+ ## [1.10.18] - 2026-10-29
57
+
58
+ ### Fixed
59
+ - Preserve task payloads, arguments, results, identities, links, and timestamps through scheduled/policy archival and restore. Copy and delete exact locked row IDs atomically; keep parents referenced by live tasks hot and roll back restores on missing dependencies or occupied IDs (#75).
60
+ - Add migration 137 for missing task archive columns; existing migrations are unchanged. PostgreSQL generated timestamps are excluded from restore inserts.
61
+
3
62
  ## [1.10.17] - 2026-10-29
4
63
 
5
64
  ### Fixed
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Persistent database storage for the [LegionIO](https://github.com/LegionIO/LegionIO) async job engine and AI coding assistant platform. Provides database connectivity via the [Sequel ORM](https://sequel.jeremyevans.net/), automatic schema migrations (97 numbered migrations), Sequel models for the full LegionIO control plane, and a parallel local SQLite database for on-node agentic cognitive state.
4
4
 
5
- **Version**: 1.10.17 | **Ruby**: >= 3.4 | **License**: Apache-2.0
5
+ **Version**: 1.10.18 | **Ruby**: >= 3.4 | **License**: Apache-2.0
6
6
 
7
7
  ---
8
8
 
@@ -73,7 +73,7 @@ Legion::Data (singleton module)
73
73
  │ ├── .connection_info # Adapter, liveness, and fallback diagnostics
74
74
  │ ├── .fallback_active? # True when dev fallback moved a network DB to SQLite
75
75
  │ ├── .stats # Pool metrics, tuning snapshot, adapter-specific DB stats
76
- │ └── .shutdown # Disconnect and close query file logger
76
+ │ └── .shutdown # Drain accepted work; return true only after cleanup
77
77
  │
78
78
  ├── Migration # Auto-migration system (97 numbered Sequel DSL migrations)
79
79
  │
@@ -151,6 +151,14 @@ Legion::Data::Connection.connection_info
151
151
  Legion::Data.shutdown
152
152
  ```
153
153
 
154
+ ### Graceful Database Shutdown
155
+
156
+ `Legion::Data.shutdown` returns `true` when the shared pool has drained and its connections and query logger are closed. An idle pool closes immediately. Once shutdown starts, new checkouts are rejected; work already accepted by the pool may finish, including nested checkouts in the same execution context.
157
+
158
+ If `shutdown_timeout` expires, shutdown returns `false` rather than claiming active connections were forcibly closed. The last accepted checkout performs deferred cleanup. `Connection.connection_info[:shutdown]` reports `state` (`open`, `draining`, `closing`, `closed`, or `failed`) and `active_requests`, which counts accepted execution contexts, including pool waiters—not physical connections. A cleanup failure is logged and can be retried by calling shutdown again.
159
+
160
+ Shutdown does not cancel SQL, kill worker threads, or close borrowed connections. Configure database statement timeouts separately to bound query execution. To resume work after completed shutdown, call `Legion::Data.setup`; it creates a new pool and rebinds loaded models.
161
+
154
162
  ### Model Associations
155
163
 
156
164
  Models use Sequel associations as the public object graph. Prefer association methods and association datasets over hand-written foreign-key lookups when the relationship is part of the schema contract.
@@ -221,16 +229,48 @@ task = Legion::Data::Model::Task.create_idempotent(
221
229
  )
222
230
  ```
223
231
 
232
+ Terminal tasks and expired TTLs allow a replacement. This database helper uses a lookup followed by an insert; concurrent callers are not guaranteed to receive the same task. It does not provide RabbitMQ delivery or execution deduplication.
233
+
234
+ ### Serialized Audit and Governance Appends
235
+
236
+ After migration 138, `AuditRecord.append` serializes each chain and `EventStore.append` serializes each stream with database-backed claims, including empty chains. Different audit/event namespaces remain independent. Claim rows do not accumulate after transactions finish; failed appends roll back both claim and record.
237
+
238
+ Audit tails, verification, and walks use append IDs rather than timestamps, preserving chain order across writer clock skew. Governance events retain their sequential stream numbering. PostgreSQL lock/statement timeouts and native adapter lock limits bound contention; timeout and unrelated database errors propagate rather than being retried indefinitely. MySQL claims use exclusive duplicate-key locking to avoid InnoDB shared-lock upgrade deadlocks.
239
+
240
+ ### Bounded Retention
241
+
242
+ ```ruby
243
+ Legion::Data::Retention.archive_old_records(
244
+ table: :audit_log, archive_after_days: 90, batch_size: 1000
245
+ ) # => { archived: total_moved, table: :audit_log }
246
+ ```
247
+
248
+ Source tables need a declared primary key, including composite keys. `batch_size` must be a positive integer and bounds each materialized batch. Rows are selected in primary-key order; PostgreSQL/MySQL lock them and SQLite uses an immediate write transaction. Each batch copies and deletes exactly the same identities atomically, including when other rows become eligible during the copy.
249
+
250
+ Outside a caller-owned transaction, each batch commits independently. A later failure is raised without undoing earlier batches; rerun to archive the remaining eligible rows. A successful result sums that invocation's moved rows. If the caller wraps retention in its own transaction, that outer transaction still controls commit and lock lifetime.
251
+
252
+ ### Audit Verification After Retention
253
+
254
+ `Legion::Data::Model::AuditLog.verify_chain` verifies hot `audit_log` rows together with retained `audit_log_archive` rows in one ordered SQL statement when the archive exists. Moving records between these tables does not discard their predecessors or create a mixed-snapshot boundary. Archived content is rehashed; missing predecessors, modified content, and duplicate copies remain verification failures.
255
+
256
+ Passing an explicit record collection still verifies exactly that collection from genesis. Cold storage and purged history are not silently trusted: missing archived predecessors remain invalid until the complete history is supplied. This does not change LegionIO's separate tier-verification API.
257
+
258
+ Before the first retention run, verification reads hot-only history without creating archive schema. It checks again afterward for concurrent archive creation and, if the archive appeared, verifies both tables together. Only an actual missing-table error means the archive is absent; permission, connection, and other database failures propagate.
259
+
224
260
  ### Filesystem Spool (Write Buffer)
225
261
 
226
262
  When the database is unavailable, `Legion::Data::Spool` buffers writes to `~/.legionio/data/spool/` and replays once the connection is restored:
227
263
 
228
264
  ```ruby
229
265
  spool = Legion::Data::Spool.for(Legion::Extensions::MyLex)
230
- spool.write({ task_id: SecureRandom.uuid, data: payload })
231
- spool.drain { |entry| process(entry) }
266
+ spool.write(:events, { task_id: SecureRandom.uuid, data: payload })
267
+ spool.flush(:events) { |entry| process(entry) }
232
268
  ```
233
269
 
270
+ Replay claims each event with a nonblocking advisory file lock held through the callback and successful deletion. Concurrent processes cannot replay the same live claim. `read` and `clear` skip in-flight entries; `count` includes them until deletion. Corrupt files are quarantined under the same ownership protocol.
271
+
272
+ Callback failure retains the event and releases its lock. Process exit also releases the lock automatically, so the next drainer can recover it without stale claim markers. Delivery is **at least once**: a crash after an external side effect but before deletion can replay that effect. Callbacks must provide their own idempotency. Single-drainer filename order is preserved, but concurrent completion order is not guaranteed. The spool filesystem must support cooperative advisory locking.
273
+
234
274
  ### Row-Level Security (PostgreSQL)
235
275
 
236
276
  `Legion::Data::Rls` provides tenant isolation via PostgreSQL session variables (migration 043):
@@ -251,6 +291,10 @@ Legion::Data.can_read?(:tasks) # => true
251
291
  Legion::Data.reset_privileges! # clear cached privilege checks
252
292
  ```
253
293
 
294
+ Checks use the active database adapter, including SQLite fallback, and return false while disconnected. PostgreSQL checks table-level SELECT/INSERT grants. MySQL checks base tables with a zero-row SELECT and a nonexecuting `EXPLAIN INSERT`; checking a MySQL view also requires the privileges needed to explain that view, including SHOW VIEW for writes. Neither probe inserts data.
295
+
296
+ Successful lookups are cached per physical connection. Connection replacement and credential rotation invalidate caches; query or connection failures return false without caching a denial. Call `reset_privileges!` after changing grants or roles on existing sessions.
297
+
254
298
  ---
255
299
 
256
300
  ## Configuration
@@ -415,6 +459,12 @@ Enable `data.query_log` (or `data.local.query_log`) to write SQL to a dedicated
415
459
 
416
460
  Replica URLs initialize a sharded pool at startup. URL-specific options override primary connection options; omitted credentials and session timeouts are inherited. Duplicate URLs are registered once as `read_0`, `read_1`, and so on. Reads remain on the primary unless explicitly routed, for example `dataset.server(Legion::Data::Connection.read_server)`. With no replicas configured, `read_server` returns `:default`.
417
461
 
462
+ ### Task Database Archival
463
+
464
+ `Archival.archive_completed_tasks` and task-policy archival retain every task field after migration 137. Copy/delete and restore are atomic; only copied task IDs are deleted. A task referenced as a parent or master by another live task remains hot, so linked task trees archive from leaves upward across batches.
465
+
466
+ Restore preserves original IDs and restores parent/master links after inserting the requested rows. Include archived ancestors in the same restore request or restore them first. Occupied IDs or missing referenced functions/relationships cause rollback and leave the archive intact. Fields already discarded by older archival versions cannot be reconstructed by this migration.
467
+
418
468
  ### Archive Objects
419
469
 
420
470
  Each archive batch receives a UUID before upload. S3, Azure, and local object paths use `legion-archive/<table>/<year>/<month>/batch_<uuid>.jsonl.gz`; the same UUID is stored in `archive_manifest.batch_id`. Repeated runs do not reuse numbered object names. Existing manifest paths remain valid; no stored objects are renamed. Direct `Archiver.upload_batch` callers must supply `batch_id:` instead of `batch_n:`.
@@ -10,6 +10,11 @@ module Legion
10
10
  tasks: :tasks_archive,
11
11
  metering_records: :metering_records_archive
12
12
  }.freeze
13
+ COLUMN_MAP = { id: :original_id, created_at: :original_created_at, updated_at: :original_updated_at }.freeze
14
+ TASK_COLUMN_MAP = {
15
+ id: :original_id, created: :original_created_at, updated: :original_updated_at,
16
+ function_args: :args, results: :result
17
+ }.freeze
13
18
 
14
19
  class << self
15
20
  include Legion::Logging::Helper
@@ -42,17 +47,17 @@ module Legion
42
47
  return 0 unless archive_table && db_ready?(archive_table)
43
48
 
44
49
  conn = Legion::Data.connection
45
- restored = 0
46
- conn.transaction do
47
- conn[archive_table].where(original_id: ids).each do |row|
48
- restore_row = row.except(:id, :archived_at, :original_id, :original_created_at, :original_updated_at)
49
- restore_row[:id] = row[:original_id]
50
- restore_row[:created_at] = row[:original_created_at]
51
- restore_row[:updated_at] = row[:original_updated_at]
52
- conn[source_table].insert(restore_row)
53
- restored += 1
50
+ restored = conn.transaction(savepoint: true, **(conn.database_type == :sqlite ? { mode: :immediate } : {})) do
51
+ dataset = conn[archive_table].where(original_id: ids).order(:original_id)
52
+ dataset = dataset.for_update unless conn.database_type == :sqlite
53
+ archived = dataset.all
54
+ rows = restore_row_values(conn: conn, source: source_table, archived: archived)
55
+ rows.each do |row|
56
+ conn[source_table].insert(source_table == :tasks ? row.merge(parent_id: nil, master_id: nil) : row)
54
57
  end
55
- conn[archive_table].where(original_id: ids).delete
58
+ rows.each { |row| conn[:tasks].where(id: row[:id]).update(row.slice(:parent_id, :master_id)) } if source_table == :tasks
59
+ conn[archive_table].where(id: archived.map { |row| row[:id] }).delete
60
+ rows.size
56
61
  end
57
62
  log.info "Restored #{restored} row(s) from #{archive_table} -> #{source_table}"
58
63
  restored
@@ -82,30 +87,8 @@ module Legion
82
87
 
83
88
  return { archived: 0, cutoff: cutoff.iso8601 } unless conn&.table_exists?(:tasks) && conn.table_exists?(:tasks_archive)
84
89
 
85
- candidates = conn[:tasks]
86
- .where(status: %w[completed failed])
87
- .where(Sequel.lit('created < ?', cutoff))
88
- .limit(batch_size)
89
-
90
- count = candidates.count
91
- if count.positive?
92
- archive_cols = conn.schema(:tasks_archive).to_set(&:first)
93
- conn.transaction do
94
- candidates.each do |row|
95
- archive_row = {
96
- original_id: row[:id],
97
- status: row[:status],
98
- relationship_id: row[:relationship_id],
99
- original_created_at: row[:created],
100
- original_updated_at: row[:updated],
101
- archived_at: Time.now
102
- }
103
- archive_row[:archive_reason] = 'completed_task_archival' if archive_cols.include?(:archive_reason)
104
- conn[:tasks_archive].insert(archive_row)
105
- end
106
- conn[:tasks].where(id: candidates.select(:id)).delete
107
- end
108
- end
90
+ count = archive_table!(source: :tasks, destination: :tasks_archive, cutoff: cutoff,
91
+ batch_size: batch_size, dry_run: false, statuses: %w[completed failed])
109
92
 
110
93
  log.info "archive_completed_tasks: archived #{count} tasks (cutoff: #{cutoff.iso8601})"
111
94
  { archived: count, cutoff: cutoff.iso8601 }
@@ -135,24 +118,40 @@ module Legion
135
118
 
136
119
  private
137
120
 
138
- def archive_table!(source:, destination:, cutoff:, batch_size:, dry_run:)
121
+ def archive_table!(source:, destination:, cutoff:, batch_size:, dry_run:, statuses: nil)
139
122
  conn = Legion::Data.connection
140
- candidates = conn[source].where { created_at < cutoff }.limit(batch_size)
141
- count = candidates.count
142
- return count if dry_run || count.zero?
143
-
144
- conn.transaction do
145
- candidates.each do |row|
146
- archive_row = row.dup
147
- archive_row[:original_id] = archive_row.delete(:id)
148
- archive_row[:original_created_at] = archive_row.delete(:created_at)
149
- archive_row[:original_updated_at] = archive_row.delete(:updated_at)
150
- archive_row[:archived_at] = Time.now
151
- conn[destination].insert(archive_row)
123
+ date_column = Policy::DATE_COLUMN_OVERRIDES.fetch(source.to_s, :created_at)
124
+ candidates = conn[source].where(Sequel[date_column] < cutoff).order(:id).limit(batch_size)
125
+ candidates = candidates.where(status: statuses) if statuses
126
+ if source == :tasks
127
+ references = conn.from(Sequel.as(:tasks, :dependent_tasks))
128
+ .where(Sequel.|({ parent_id: Sequel[:tasks][:id] }, { master_id: Sequel[:tasks][:id] }))
129
+ .exclude(id: Sequel[:tasks][:id]).exists
130
+ candidates = candidates.exclude(references)
131
+ end
132
+ return candidates.count if dry_run
133
+
134
+ mapping = source == :tasks ? TASK_COLUMN_MAP : COLUMN_MAP
135
+ conn.transaction(savepoint: true, **(conn.database_type == :sqlite ? { mode: :immediate } : {})) do
136
+ locked = conn.database_type == :sqlite ? candidates : candidates.for_update
137
+ selected_ids = locked.select_map(:id)
138
+ selected = conn[source].where(id: selected_ids).order(:id)
139
+ selected = selected.exclude(references) if source == :tasks
140
+ selected = selected.for_update unless conn.database_type == :sqlite
141
+ rows = selected.all
142
+ rows.each do |row|
143
+ archive_row = row.to_h { |column, value| [mapping.fetch(column, column), value] }
144
+ conn[destination].insert(archive_row.merge(archived_at: Time.now))
152
145
  end
153
- conn[source].where(id: candidates.select(:id)).delete
146
+ conn[source].where(id: rows.map { |row| row[:id] }).delete
147
+ rows.size
154
148
  end
155
- count
149
+ end
150
+
151
+ def restore_row_values(conn:, source:, archived:)
152
+ mapping = source == :tasks ? TASK_COLUMN_MAP : COLUMN_MAP
153
+ columns = conn.schema(source).reject { |_, info| info[:generated] }.map(&:first)
154
+ archived.map { |row| columns.to_h { |column| [column, row[mapping.fetch(column, column)]] } }
156
155
  end
157
156
 
158
157
  def db_ready?(table)
@@ -19,6 +19,7 @@ module Legion
19
19
 
20
20
  def verify(records)
21
21
  previous_hash = GENESIS_HASH
22
+ length = 0
22
23
  records.each do |record|
23
24
  return invalid(record, :parent_mismatch) unless value_for(record, :previous_hash).to_s == previous_hash
24
25
 
@@ -26,9 +27,10 @@ module Legion
26
27
  return invalid(record, :hash_mismatch) unless value_for(record, :record_hash).to_s == expected
27
28
 
28
29
  previous_hash = expected
30
+ length += 1
29
31
  end
30
32
 
31
- { valid: true, length: records.size }
33
+ { valid: true, length: length }
32
34
  end
33
35
 
34
36
  def canonical_payload(record)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/logging/helper'
4
4
  require 'digest'
5
+ require 'legion/data/database_lock'
5
6
 
6
7
  module Legion
7
8
  module Data
@@ -23,7 +24,8 @@ module Legion
23
24
  return { error: 'db unavailable' } unless db_ready?
24
25
 
25
26
  conn = Legion::Data.connection
26
- conn.transaction do
27
+ Legion::Data::DatabaseLock.with_claim(database: conn, table: :append_claims,
28
+ key: { kind: 'audit', stream_id: chain_id }) do
27
29
  parent_hash = latest_chain_hash(conn, chain_id)
28
30
  ts = truncate_to_us(Time.now)
29
31
  ch = compute_chain_hash(parent_hash, content_hash, ts, content_type)
@@ -46,7 +48,7 @@ module Legion
46
48
  end
47
49
  end
48
50
 
49
- # Walk all records in the chain ordered by creation time and verify that
51
+ # Walk all records in append order and verify that
50
52
  # each record's stored chain_hash matches a freshly computed one.
51
53
  #
52
54
  # @param chain_id [String]
@@ -56,7 +58,7 @@ module Legion
56
58
 
57
59
  records = Legion::Data.connection[:audit_records]
58
60
  .where(chain_id: chain_id)
59
- .order(:created_at, :id)
61
+ .order(:id)
60
62
  .all
61
63
 
62
64
  prev_hash = GENESIS_HASH
@@ -88,7 +90,7 @@ module Legion
88
90
 
89
91
  ds = Legion::Data.connection[:audit_records].where(chain_id: chain_id)
90
92
  ds = ds.where { created_at >= since } if since
91
- ds.order(:created_at, :id).limit(limit).all.map { |r| deserialize(r) }
93
+ ds.order(:id).limit(limit).all.map { |r| deserialize(r) }
92
94
  end
93
95
 
94
96
  # Return records filtered by content_type across all chains.
@@ -136,11 +138,12 @@ module Legion
136
138
  end
137
139
 
138
140
  def latest_chain_hash(conn, chain_id)
139
- last = conn[:audit_records]
140
- .select(:chain_hash)
141
- .where(chain_id: chain_id)
142
- .order(Sequel.desc(:created_at), Sequel.desc(:id))
143
- .first
141
+ records = conn[:audit_records]
142
+ .select(:chain_hash)
143
+ .where(chain_id: chain_id)
144
+ .order(Sequel.desc(:id))
145
+ records = records.for_update unless conn.database_type == :sqlite
146
+ last = records.first
144
147
  last ? last[:chain_hash] : GENESIS_HASH
145
148
  end
146
149
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/logging/helper'
4
4
  require 'legion/data/auth_failure_handler'
5
+ require 'legion/data/connection_pool'
5
6
 
6
7
  require 'fileutils'
7
8
  require 'sequel'
@@ -165,7 +166,16 @@ module Legion
165
166
  class << self
166
167
  include Legion::Logging::Helper
167
168
 
168
- attr_accessor :sequel
169
+ attr_reader :sequel
170
+
171
+ def sequel=(database)
172
+ if database
173
+ pool = database.pool
174
+ pool.extend(Legion::Data::ConnectionPool) unless pool.is_a?(Legion::Data::ConnectionPool)
175
+ end
176
+ Legion::Data.reset_privileges!
177
+ @sequel = database
178
+ end
169
179
 
170
180
  def adapter
171
181
  @adapter ||= Legion::Settings[:data][:adapter]&.to_sym || :sqlite
@@ -180,28 +190,28 @@ module Legion
180
190
  log.info("Legion::Data::Connection setup adapter=#{adapter}")
181
191
  @fallback_active = false
182
192
  @replica_servers = []
183
- @sequel = if adapter == :sqlite
184
- ::Sequel.connect(opts.merge(adapter: :sqlite, database: sqlite_path))
185
- else
186
- attempted_adapter = adapter
187
- begin
188
- ::Sequel.connect(connection_opts_for(adapter: attempted_adapter, opts: opts))
189
- rescue UnresolvedCredentialError => e
190
- handle_exception(e, level: :fatal, handled: false, operation: :data_connect)
191
- raise
192
- rescue ::Sequel::DatabaseConnectionError => e
193
- raise unless dev_fallback?
194
-
195
- log.error("Legion::Data FALLING BACK TO SQLITE — #{attempted_adapter} network DB connection failed: #{e.message}")
196
- log.error("Legion::Data WARNING: Data written to SQLite will NOT be visible when #{attempted_adapter} reconnects. " \
197
- 'Apollo knowledge, audit logs, and other DB-backed services will use a local-only store.')
198
- handle_exception(e, level: :error, handled: true, operation: :shared_connect, fallback: :sqlite)
199
- @adapter = :sqlite
200
- @fallback_active = true
201
- sqlite_opts = sequel_opts
202
- ::Sequel.connect(sqlite_opts.merge(adapter: :sqlite, database: sqlite_path))
203
- end
204
- end
193
+ self.sequel = if adapter == :sqlite
194
+ ::Sequel.connect(opts.merge(adapter: :sqlite, database: sqlite_path))
195
+ else
196
+ attempted_adapter = adapter
197
+ begin
198
+ ::Sequel.connect(connection_opts_for(adapter: attempted_adapter, opts: opts))
199
+ rescue UnresolvedCredentialError => e
200
+ handle_exception(e, level: :fatal, handled: false, operation: :data_connect)
201
+ raise
202
+ rescue ::Sequel::DatabaseConnectionError => e
203
+ raise unless dev_fallback?
204
+
205
+ log.error("Legion::Data FALLING BACK TO SQLITE — #{attempted_adapter} network DB connection failed: #{e.message}")
206
+ log.error("Legion::Data WARNING: Data written to SQLite will NOT be visible when #{attempted_adapter} reconnects. " \
207
+ 'Apollo knowledge, audit logs, and other DB-backed services will use a local-only store.')
208
+ handle_exception(e, level: :error, handled: true, operation: :shared_connect, fallback: :sqlite)
209
+ @adapter = :sqlite
210
+ @fallback_active = true
211
+ sqlite_opts = sequel_opts
212
+ ::Sequel.connect(sqlite_opts.merge(adapter: :sqlite, database: sqlite_path))
213
+ end
214
+ end
205
215
  @replica_servers = @sequel.opts.fetch(:servers, {}).keys
206
216
  log_connection_info
207
217
  configure_extensions
@@ -221,6 +231,7 @@ module Legion
221
231
  connected: Legion::Settings[:data][:connected],
222
232
  fallback_active: @fallback_active || false,
223
233
  configured_adapter: Legion::Settings[:data][:adapter]&.to_sym || :sqlite,
234
+ shutdown: @sequel&.pool&.shutdown_status,
224
235
  sequel_alive: (begin
225
236
  !@sequel&.test_connection.nil?
226
237
  rescue StandardError => e
@@ -287,16 +298,26 @@ module Legion
287
298
  end
288
299
 
289
300
  def shutdown
290
- if @sequel
291
- timeout = Legion::Settings[:data][:shutdown_timeout]
292
- force_disconnect_pool(timeout: timeout)
301
+ database = @sequel
302
+ logger = @query_file_logger
303
+ on_closed = proc do
304
+ logger&.close
305
+ if @sequel.equal?(database)
306
+ @query_file_logger = nil
307
+ @fallback_active = false
308
+ @replica_servers = []
309
+ Legion::Settings[:data][:connected] = false
310
+ end
311
+ log.info 'Legion::Data connection closed'
293
312
  end
294
- @query_file_logger&.close
295
- @query_file_logger = nil
296
- @fallback_active = false
297
- @replica_servers = []
298
- Legion::Settings[:data][:connected] = false
299
- log.info 'Legion::Data connection closed'
313
+ unless database
314
+ on_closed.call
315
+ return true
316
+ end
317
+
318
+ closed = database.pool.shutdown(timeout: Legion::Settings[:data][:shutdown_timeout], &on_closed)
319
+ log.warn("Legion::Data shutdown incomplete: #{database.pool.shutdown_status}") unless closed
320
+ closed
300
321
  end
301
322
 
302
323
  def reconnect_with_fresh_creds
@@ -317,6 +338,7 @@ module Legion
317
338
  old_user = @sequel.opts[:user]
318
339
  @sequel.opts[:user] = new_user
319
340
  @sequel.opts[:password] = new_pass
341
+ Legion::Data.reset_privileges!
320
342
 
321
343
  @sequel.disconnect
322
344
  expire_all_pooled_connections
@@ -441,28 +463,6 @@ module Legion
441
463
  data_settings[:dev_mode] == true && data_settings[:dev_fallback] != false
442
464
  end
443
465
 
444
- def force_disconnect_pool(timeout:)
445
- pool = @sequel.pool
446
- in_use = pool.size - (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
447
-
448
- if in_use.positive?
449
- log.warn("Legion::Data shutdown: #{in_use} connection(s) still checked out, waiting up to #{timeout}s")
450
- deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout
451
- while ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < deadline
452
- break if pool.size <= (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
453
-
454
- sleep 0.1
455
- end
456
- remaining = pool.size - (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
457
- log.warn("Legion::Data shutdown: force-disconnecting #{remaining} wedged connection(s)") if remaining.positive?
458
- end
459
-
460
- @sequel.disconnect
461
- rescue StandardError => e
462
- handle_exception(e, level: :warn, handled: true, operation: :force_disconnect_pool)
463
- @sequel&.disconnect rescue nil # rubocop:disable Style/RescueModifier
464
- end
465
-
466
466
  def sqlite_path
467
467
  path = Legion::Settings[:data][:creds][:database] || 'legionio.db'
468
468
  return path if File.absolute_path?(path)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging/helper'
4
+
5
+ module Legion
6
+ module Data
7
+ module ConnectionPool
8
+ include Legion::Logging::Helper
9
+
10
+ def self.extended(pool)
11
+ pool.instance_variable_set(:@shutdown_mutex, Mutex.new)
12
+ pool.instance_variable_set(:@shutdown_condition, ConditionVariable.new)
13
+ pool.instance_variable_set(:@shutdown_owners, {}.compare_by_identity)
14
+ pool.instance_variable_set(:@shutdown_state, :open)
15
+ end
16
+
17
+ def hold(server = :default)
18
+ owner = Sequel.current
19
+ @shutdown_mutex.synchronize do
20
+ raise Sequel::Error, 'Database pool is shutting down or closed' unless @shutdown_state == :open || @shutdown_owners.key?(owner)
21
+
22
+ @shutdown_owners[owner] = @shutdown_owners.fetch(owner, 0) + 1
23
+ end
24
+ begin
25
+ super
26
+ ensure
27
+ finish = @shutdown_mutex.synchronize do
28
+ @shutdown_owners[owner] -= 1
29
+ @shutdown_owners.delete(owner) if @shutdown_owners[owner].zero?
30
+ if @shutdown_state == :draining && @shutdown_owners.empty?
31
+ @shutdown_state = :closing
32
+ true
33
+ end
34
+ end
35
+ finish_shutdown if finish
36
+ end
37
+ end
38
+
39
+ def shutdown(timeout:, &on_closed)
40
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout
41
+ finish = @shutdown_mutex.synchronize do
42
+ @shutdown_callback ||= on_closed
43
+ @shutdown_state = :draining if %i[open failed].include?(@shutdown_state)
44
+ if @shutdown_state == :draining && @shutdown_owners.empty?
45
+ @shutdown_state = :closing
46
+ true
47
+ end
48
+ end
49
+ finish_shutdown if finish
50
+ @shutdown_mutex.synchronize do
51
+ until @shutdown_state == :closed
52
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
53
+ return false if remaining <= 0 || @shutdown_state == :failed || @shutdown_owners.key?(Sequel.current)
54
+
55
+ @shutdown_condition.wait(@shutdown_mutex, remaining)
56
+ end
57
+ end
58
+ true
59
+ end
60
+
61
+ def shutdown_status
62
+ @shutdown_mutex.synchronize { { state: @shutdown_state, active_requests: @shutdown_owners.size } }
63
+ end
64
+
65
+ private
66
+
67
+ def finish_shutdown
68
+ disconnect
69
+ @shutdown_callback.call
70
+ @shutdown_mutex.synchronize do
71
+ @shutdown_state = :closed
72
+ @shutdown_condition.broadcast
73
+ end
74
+ rescue StandardError => e
75
+ @shutdown_mutex.synchronize do
76
+ @shutdown_state = :failed
77
+ @shutdown_condition.broadcast
78
+ end
79
+ handle_exception(e, level: :error, handled: true, operation: :pool_shutdown)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Data
5
+ module DatabaseLock
6
+ def self.with_claim(database:, table:, key:)
7
+ options = { savepoint: true }
8
+ options[:mode] = :immediate if database.database_type == :sqlite
9
+ database.transaction(**options) do
10
+ claim = database[table].where(key)
11
+ # InnoDB needs exclusive duplicate-key locks to avoid shared-lock upgrade deadlocks.
12
+ claim = claim.on_duplicate_key_update(*key.keys) if database.database_type == :mysql
13
+ claim.insert(key)
14
+ result = yield
15
+ # The unique-key lock lasts until commit; historical keys need no retained rows.
16
+ claim.delete
17
+ result
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/logging/helper'
4
4
  require 'digest'
5
+ require 'legion/data/database_lock'
5
6
 
6
7
  module Legion
7
8
  module Data
@@ -21,11 +22,11 @@ module Legion
21
22
  return { error: 'db unavailable' } unless db_ready?
22
23
 
23
24
  conn = Legion::Data.connection
24
- conn.transaction do
25
- last = conn[:governance_events]
26
- .where(stream_id: stream)
27
- .order(Sequel.desc(:sequence_number))
28
- .first
25
+ Legion::Data::DatabaseLock.with_claim(database: conn, table: :append_claims,
26
+ key: { kind: 'event', stream_id: stream }) do
27
+ records = conn[:governance_events].where(stream_id: stream).order(Sequel.desc(:sequence_number))
28
+ records = records.for_update unless conn.database_type == :sqlite
29
+ last = records.first
29
30
 
30
31
  seq = (last&.[](:sequence_number) || 0) + 1
31
32
  prev_hash = last&.[](:event_hash) || ('0' * 64)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ alter_table(:tasks_archive) do
6
+ add_column :function_id, Integer
7
+ add_column :parent_id, Integer
8
+ add_column :master_id, Integer
9
+ add_column :payload, String, text: true
10
+ add_column :worker_id, String, size: 36
11
+ add_column :tenant_id, String, size: 64
12
+ add_column :depth, Integer
13
+ add_column :cancelled_at, DateTime
14
+ add_column :task_delay, Integer
15
+ add_column :created_at, DateTime
16
+ add_column :idempotency_key, String, size: 64
17
+ add_column :idempotency_expires_at, DateTime
18
+ end
19
+ end
20
+
21
+ down do
22
+ alter_table(:tasks_archive) do
23
+ drop_column :function_id
24
+ drop_column :parent_id
25
+ drop_column :master_id
26
+ drop_column :payload
27
+ drop_column :worker_id
28
+ drop_column :tenant_id
29
+ drop_column :depth
30
+ drop_column :cancelled_at
31
+ drop_column :task_delay
32
+ drop_column :created_at
33
+ drop_column :idempotency_key
34
+ drop_column :idempotency_expires_at
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table(:append_claims) do
6
+ String :kind, size: 16, null: false
7
+ String :stream_id, size: 255, null: false
8
+ primary_key %i[kind stream_id]
9
+ end
10
+ end
11
+ end
@@ -30,7 +30,10 @@ module Legion
30
30
  def load
31
31
  log.info 'Loading Legion::Data::Models'
32
32
  @loaded_models ||= []
33
+ database = Legion::Data::Connection.sequel
34
+ Sequel::Model.db = database
33
35
  require_sequel_models(models)
36
+ bind_database(namespace: Legion::Data::Model, database: database)
34
37
  Legion::Settings[:data][:models][:loaded] = true
35
38
  end
36
39
 
@@ -49,6 +52,22 @@ module Legion
49
52
  handle_exception(e, level: :fatal, operation: :load_sequel_model, model: model)
50
53
  raise e unless Legion::Settings[:data][:models][:continue_on_load_fail]
51
54
  end
55
+
56
+ private
57
+
58
+ def bind_database(namespace:, database:)
59
+ namespace.constants(false).each do |name|
60
+ value = namespace.const_get(name, false)
61
+ if value.is_a?(Class) && value < Sequel::Model
62
+ next if value.db.equal?(database)
63
+
64
+ value.set_dataset(database[value.table_name])
65
+ value.association_reflections.each_value { |reflection| reflection[:cache]&.clear }
66
+ elsif value.is_a?(Module)
67
+ bind_database(namespace: value, database: database)
68
+ end
69
+ end
70
+ end
52
71
  end
53
72
  end
54
73
  end
@@ -8,9 +8,11 @@ module Legion
8
8
  module Model
9
9
  class AuditLog < Sequel::Model(:audit_log)
10
10
  include Legion::Logging::Helper
11
+ extend Legion::Logging::Helper
11
12
 
12
13
  VALID_EVENT_TYPES = %w[runner_execution lifecycle_transition].freeze
13
14
  VALID_STATUSES = %w[success failure denied].freeze
15
+ HASH_CHAIN_COLUMNS = [:id, :record_hash, *Legion::Data::AuditLogHashChain::CANONICAL_FIELDS].freeze
14
16
 
15
17
  def validate
16
18
  super
@@ -39,9 +41,39 @@ module Legion
39
41
  Legion::Data::AuditLogHashChain.compute_hash(record)
40
42
  end
41
43
 
42
- def self.verify_chain(records = order(:created_at, :id).all)
43
- Legion::Data::AuditLogHashChain.verify(records)
44
+ def self.verify_chain(records = nil)
45
+ return Legion::Data::AuditLogHashChain.verify(records) unless records.nil?
46
+
47
+ hot = dataset.naked.select(*HASH_CHAIN_COLUMNS)
48
+ unless audit_archive_exists?
49
+ result = Legion::Data::AuditLogHashChain.verify(hot.order(:created_at, :id))
50
+ # Retention may create the archive and move rows after the first presence check.
51
+ return result unless audit_archive_exists?
52
+ end
53
+
54
+ warm = db[:audit_log_archive].select(*HASH_CHAIN_COLUMNS)
55
+ Legion::Data::AuditLogHashChain.verify(hot.union(warm, all: true).order(:created_at, :id))
56
+ end
57
+
58
+ def self.audit_archive_exists?
59
+ db.transaction(savepoint: :only) { db[:audit_log_archive].get(Sequel::NULL) }
60
+ true
61
+ rescue Sequel::DatabaseError => e
62
+ cause = e.wrapped_exception
63
+ missing = case db.database_type
64
+ when :postgres
65
+ cause.is_a?(::PG::UndefinedTable)
66
+ when :mysql
67
+ cause.respond_to?(:error_number) && cause.error_number == 1146
68
+ when :sqlite
69
+ cause.is_a?(::SQLite3::SQLException) && cause.message.start_with?('no such table: audit_log_archive')
70
+ end
71
+ raise unless missing
72
+
73
+ handle_exception(e, level: :debug, handled: true, operation: :audit_archive_presence)
74
+ false
44
75
  end
76
+ private_class_method :audit_archive_exists?
45
77
  end
46
78
  end
47
79
  end
@@ -12,24 +12,36 @@ module Legion
12
12
  class << self
13
13
  include Legion::Logging::Helper
14
14
 
15
- def archive_old_records(table:, date_column: nil, archive_after_days: DEFAULT_ARCHIVE_AFTER_DAYS)
15
+ def archive_old_records(table:, date_column: nil, archive_after_days: DEFAULT_ARCHIVE_AFTER_DAYS, batch_size: 1000)
16
16
  db = Legion::Data.connection
17
17
  return { archived: 0, table: table } unless db
18
+ raise ArgumentError, 'batch_size must be a positive Integer' unless batch_size.is_a?(Integer) && batch_size.positive?
18
19
 
19
20
  date_column = resolve_date_column(table, date_column)
20
21
  cutoff = Time.now - (archive_after_days * 86_400)
21
22
  archive_table = archive_table_name(table)
23
+ primary_keys = db.schema(table).filter_map { |name, info| name if info[:primary_key] }
24
+ raise Sequel::Error, "#{table} needs a primary key for bounded retention" if primary_keys.empty?
22
25
 
23
26
  ensure_archive_table!(db, table, archive_table)
24
27
 
25
28
  count = 0
26
- db.transaction do
27
- records = db[table].where(Sequel.identifier(date_column) < cutoff)
28
- count = records.count
29
- if count.positive?
30
- db[archive_table].multi_insert(records.all)
31
- records.delete
29
+ loop do
30
+ archived = db.transaction(savepoint: true, **(db.database_type == :sqlite ? { mode: :immediate } : {})) do
31
+ records = db[table].where(Sequel.identifier(date_column) < cutoff).order(*primary_keys).limit(batch_size)
32
+ records = records.for_update unless db.database_type == :sqlite
33
+ rows = records.all
34
+ next 0 if rows.empty?
35
+
36
+ db[archive_table].multi_insert(rows)
37
+ deleted = delete_archived_rows(db: db, table: table, rows: rows, primary_keys: primary_keys)
38
+ raise Sequel::Error, "Retention copied #{rows.length} rows but deleted #{deleted} from #{table}" unless deleted == rows.length
39
+
40
+ deleted
32
41
  end
42
+ break if archived.zero?
43
+
44
+ count += archived
33
45
  end
34
46
 
35
47
  log.info "Archived #{count} row(s) from #{table}" if count.positive?
@@ -113,18 +125,37 @@ module Legion
113
125
  end
114
126
  end
115
127
 
128
+ def delete_archived_rows(db:, table:, rows:, primary_keys:)
129
+ if primary_keys.one?
130
+ key = primary_keys.first
131
+ return db[table].where(key => rows.map { |row| row.fetch(key) }).delete
132
+ end
133
+
134
+ # SQLite expands composite-key predicates into OR expressions with a depth limit.
135
+ identity_batch_size = db.database_type == :sqlite ? 100 : rows.length
136
+ rows.each_slice(identity_batch_size).sum do |batch|
137
+ db[table].where(primary_keys => batch.map { |row| row.values_at(*primary_keys) }).delete
138
+ end
139
+ end
140
+
116
141
  def ensure_archive_table!(db, source_table, archive_table)
117
142
  return if db.table_exists?(archive_table)
118
143
 
119
144
  source_schema = db.schema(source_table).to_h
120
145
 
121
146
  log.info "Creating archive table #{archive_table} from #{source_table}"
122
- db.create_table(archive_table) do
123
- source_schema.each do |col_name, col_info|
124
- column col_name, col_info[:db_type]
147
+ db.transaction(savepoint: true) do
148
+ db.create_table?(archive_table) do
149
+ source_schema.each do |col_name, col_info|
150
+ column col_name, col_info[:db_type]
151
+ end
152
+ DateTime :archived_at, default: Sequel::CURRENT_TIMESTAMP
125
153
  end
126
- DateTime :archived_at, default: Sequel::CURRENT_TIMESTAMP
127
154
  end
155
+ rescue Sequel::UniqueConstraintViolation => e
156
+ raise unless db.table_exists?(archive_table)
157
+
158
+ handle_exception(e, level: :debug, handled: true, operation: :concurrent_archive_creation, table: archive_table)
128
159
  end
129
160
  end
130
161
  end
@@ -61,8 +61,10 @@ module Legion
61
61
 
62
62
  def read(sub_namespace)
63
63
  sorted_files(sub_namespace).each_with_object([]) do |path, events|
64
- event = load_event_file(path, sub_namespace)
65
- events << event if event
64
+ with_locked_event(path) do |file|
65
+ event = load_event_file(file, path, sub_namespace)
66
+ events << event if event
67
+ end
66
68
  end
67
69
  rescue StandardError => e
68
70
  handle_exception(e, level: :error, handled: false, operation: :spool_read, sub_namespace: sub_namespace)
@@ -72,13 +74,16 @@ module Legion
72
74
  def flush(sub_namespace)
73
75
  count = 0
74
76
  path = nil
75
- sorted_files(sub_namespace).each do |path|
76
- event = load_event_file(path, sub_namespace)
77
- next unless event
78
-
79
- yield event
80
- File.delete(path)
81
- count += 1
77
+ sorted_files(sub_namespace).each do |entry_path|
78
+ path = entry_path
79
+ with_locked_event(path) do |file|
80
+ event = load_event_file(file, path, sub_namespace)
81
+ next unless event
82
+
83
+ yield event
84
+ File.delete(path)
85
+ count += 1
86
+ end
82
87
  end
83
88
  log.info "Spool drained #{count} item(s) from #{sub_namespace}" if count.positive?
84
89
  count
@@ -95,8 +100,10 @@ module Legion
95
100
  dir = sub_dir(sub_namespace)
96
101
  return unless Dir.exist?(dir)
97
102
 
98
- Dir[File.join(dir, '*.json')].each { |f| File.delete(f) }
99
- log.info "Spool cleared #{sub_namespace}"
103
+ sorted_files(sub_namespace).each do |path|
104
+ with_locked_event(path) { |_file| File.delete(path) }
105
+ end
106
+ log.info "Spool cleared unclaimed events from #{sub_namespace}"
100
107
  rescue StandardError => e
101
108
  handle_exception(e, level: :error, handled: false, operation: :spool_clear, sub_namespace: sub_namespace)
102
109
  raise
@@ -115,11 +122,24 @@ module Legion
115
122
  Dir.glob(File.join(dir, '*.json'), sort: true)
116
123
  end
117
124
 
118
- def load_event_file(path, sub_namespace)
119
- ::JSON.parse(File.binread(path), symbolize_names: true)
120
- rescue Errno::ENOENT => e
121
- log.debug("spool event file not found: #{path}: #{e.message}")
122
- nil
125
+ def with_locked_event(path)
126
+ file = begin
127
+ File.open(path, 'rb')
128
+ rescue Errno::ENOENT => e
129
+ log.debug("spool event file not found: #{path}: #{e.message}")
130
+ nil
131
+ end
132
+ return unless file
133
+ return unless file.flock(File::LOCK_EX | File::LOCK_NB)
134
+ return unless File.identical?(file, path)
135
+
136
+ yield file
137
+ ensure
138
+ file&.close
139
+ end
140
+
141
+ def load_event_file(file, path, sub_namespace)
142
+ ::JSON.parse(file.read, symbolize_names: true)
123
143
  rescue ::JSON::ParserError, EOFError, ArgumentError => e
124
144
  quarantine_corrupt_file(path, sub_namespace, e)
125
145
  nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Data
5
- VERSION = '1.10.17'
5
+ VERSION = '1.10.28'
6
6
  end
7
7
  end
data/lib/legion/data.rb CHANGED
@@ -133,36 +133,56 @@ module Legion
133
133
  def can_write?(table_name)
134
134
  return false unless connected?
135
135
 
136
- adapter = Legion::Settings[:data][:adapter]&.to_s
137
- return true if adapter == 'sqlite'
138
-
139
- @write_privileges ||= {}
140
- return @write_privileges[table_name] unless @write_privileges[table_name].nil?
141
-
142
- @write_privileges[table_name] = connection
143
- .fetch("SELECT has_table_privilege(current_user, ?, 'INSERT') AS can", table_name.to_s)
144
- .first[:can] == true
136
+ database = connection
137
+ return false unless database
138
+ return true if database.database_type == :sqlite
139
+
140
+ cache = @write_privileges ||= ObjectSpace::WeakKeyMap.new
141
+ database.synchronize do |session|
142
+ privileges = cache[session] ||= {}
143
+ return privileges[table_name] if privileges.key?(table_name)
144
+
145
+ privileges[table_name] = case database.database_type
146
+ when :postgres
147
+ database.fetch("SELECT has_table_privilege(current_user, ?, 'INSERT') AS can", table_name.to_s)
148
+ .server(:default).first[:can] == true
149
+ when :mysql
150
+ database.fetch('EXPLAIN INSERT INTO ? () VALUES ()', Sequel.identifier(table_name)).server(:default).all
151
+ true
152
+ else
153
+ raise Sequel::Error, "Unsupported privilege adapter: #{database.database_type}"
154
+ end
155
+ end
145
156
  rescue StandardError => e
146
157
  handle_exception(e, level: :warn, handled: true, operation: :can_write?, table: table_name)
147
- @write_privileges[table_name] = false if @write_privileges
148
158
  false
149
159
  end
150
160
 
151
161
  def can_read?(table_name)
152
162
  return false unless connected?
153
163
 
154
- adapter = Legion::Settings[:data][:adapter]&.to_s
155
- return true if adapter == 'sqlite'
156
-
157
- @read_privileges ||= {}
158
- return @read_privileges[table_name] unless @read_privileges[table_name].nil?
159
-
160
- @read_privileges[table_name] = connection
161
- .fetch("SELECT has_table_privilege(current_user, ?, 'SELECT') AS can", table_name.to_s)
162
- .first[:can] == true
164
+ database = connection
165
+ return false unless database
166
+ return true if database.database_type == :sqlite
167
+
168
+ cache = @read_privileges ||= ObjectSpace::WeakKeyMap.new
169
+ database.synchronize do |session|
170
+ privileges = cache[session] ||= {}
171
+ return privileges[table_name] if privileges.key?(table_name)
172
+
173
+ privileges[table_name] = case database.database_type
174
+ when :postgres
175
+ database.fetch("SELECT has_table_privilege(current_user, ?, 'SELECT') AS can", table_name.to_s)
176
+ .server(:default).first[:can] == true
177
+ when :mysql
178
+ database.fetch('SELECT * FROM ? LIMIT 0', Sequel.identifier(table_name)).server(:default).all
179
+ true
180
+ else
181
+ raise Sequel::Error, "Unsupported privilege adapter: #{database.database_type}"
182
+ end
183
+ end
163
184
  rescue StandardError => e
164
185
  handle_exception(e, level: :warn, handled: true, operation: :can_read?, table: table_name)
165
- @read_privileges[table_name] = false if @read_privileges
166
186
  false
167
187
  end
168
188
 
@@ -205,8 +225,9 @@ module Legion
205
225
 
206
226
  def shutdown
207
227
  Legion::Data::Local.shutdown if defined?(Legion::Data::Local) && Legion::Data::Local.connected?
208
- Legion::Data::Connection.shutdown
209
- log.info 'Legion::Data shutdown complete'
228
+ closed = Legion::Data::Connection.shutdown
229
+ log.info 'Legion::Data shutdown complete' if closed
230
+ closed
210
231
  end
211
232
 
212
233
  private
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.17
4
+ version: 1.10.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -126,6 +126,8 @@ files:
126
126
  - lib/legion/data/audit_record.rb
127
127
  - lib/legion/data/auth_failure_handler.rb
128
128
  - lib/legion/data/connection.rb
129
+ - lib/legion/data/connection_pool.rb
130
+ - lib/legion/data/database_lock.rb
129
131
  - lib/legion/data/encryption/cipher.rb
130
132
  - lib/legion/data/encryption/key_provider.rb
131
133
  - lib/legion/data/encryption/sequel_plugin.rb
@@ -284,6 +286,8 @@ files:
284
286
  - lib/legion/data/migrations/134_add_route_attempt_columns.rb
285
287
  - lib/legion/data/migrations/135_add_llm_context_token_accounting.rb
286
288
  - lib/legion/data/migrations/136_drop_llm_messages_seq_unique.rb
289
+ - lib/legion/data/migrations/137_preserve_task_archive_fields.rb
290
+ - lib/legion/data/migrations/138_add_append_claims.rb
287
291
  - lib/legion/data/model.rb
288
292
  - lib/legion/data/models/apollo/access_log.rb
289
293
  - lib/legion/data/models/apollo/entries.rb