legion-data 1.10.17 → 1.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/AGENTS.md +35 -2
- data/CHANGELOG.md +109 -0
- data/CLAUDE.md +1 -30
- data/Gemfile +3 -0
- data/README.md +134 -17
- data/legion-data.gemspec +1 -1
- data/lib/legion/data/archival/policy.rb +1 -16
- data/lib/legion/data/archival.rb +49 -50
- data/lib/legion/data/archiver.rb +2 -1
- data/lib/legion/data/audit_log_hash_chain.rb +3 -1
- data/lib/legion/data/audit_record.rb +25 -31
- data/lib/legion/data/connection.rb +13 -46
- data/lib/legion/data/database_lock.rb +23 -0
- data/lib/legion/data/event_store.rb +14 -14
- data/lib/legion/data/extract.rb +5 -7
- data/lib/legion/data/helper.rb +1 -1
- data/lib/legion/data/local.rb +9 -21
- data/lib/legion/data/migration.rb +24 -0
- data/lib/legion/data/migrations/137_preserve_task_archive_fields.rb +37 -0
- data/lib/legion/data/migrations/138_add_append_claims.rb +11 -0
- data/lib/legion/data/migrations/139_create_task_logs.rb +27 -0
- data/lib/legion/data/model.rb +2 -2
- data/lib/legion/data/models/append_claim.rb +10 -0
- data/lib/legion/data/models/audit_log.rb +34 -2
- data/lib/legion/data/models/data_archive.rb +10 -0
- data/lib/legion/data/models/governance_event.rb +10 -0
- data/lib/legion/data/models/task.rb +5 -5
- data/lib/legion/data/models/task_log.rb +16 -0
- data/lib/legion/data/retention.rb +42 -11
- data/lib/legion/data/settings.rb +6 -9
- data/lib/legion/data/spool.rb +36 -16
- data/lib/legion/data/storage_tiers.rb +26 -20
- data/lib/legion/data/task/status.rb +36 -0
- data/lib/legion/data/version.rb +1 -1
- data/lib/legion/data.rb +47 -23
- metadata +11 -3
data/lib/legion/data/archival.rb
CHANGED
|
@@ -10,11 +10,16 @@ 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
|
|
16
21
|
|
|
17
|
-
def archive!(policy: Policy.
|
|
22
|
+
def archive!(policy: Policy.from_settings, dry_run: false)
|
|
18
23
|
log.info "Archival run started dry_run=#{dry_run} tables=#{policy.tables.size}"
|
|
19
24
|
results = {}
|
|
20
25
|
policy.tables.each do |table_name|
|
|
@@ -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 =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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[
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
146
|
+
conn[source].where(id: rows.map { |row| row[:id] }).delete
|
|
147
|
+
rows.size
|
|
154
148
|
end
|
|
155
|
-
|
|
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)
|
data/lib/legion/data/archiver.rb
CHANGED
|
@@ -115,7 +115,8 @@ module Legion
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
def archive_batch(conn:, table:, cutoff:, batch_size:, now:, storage_backend:)
|
|
118
|
-
|
|
118
|
+
candidates = conn[table].where { created_at < cutoff }.limit(batch_size)
|
|
119
|
+
rows = candidates.all
|
|
119
120
|
return if rows.empty?
|
|
120
121
|
|
|
121
122
|
batch_id = SecureRandom.uuid
|
|
@@ -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:
|
|
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
|
|
@@ -20,17 +21,16 @@ module Legion
|
|
|
20
21
|
# @param metadata [Hash] optional structured context (serialised to JSON)
|
|
21
22
|
# @param sign [Boolean] when true, attempt signing via legion-crypt
|
|
22
23
|
def append(chain_id:, content_type:, content_hash:, metadata: {}, sign: false)
|
|
23
|
-
return { error: 'db unavailable' } unless
|
|
24
|
+
return { error: 'db unavailable' } unless Legion::Data.connected?
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
parent_hash = latest_chain_hash(conn, chain_id)
|
|
26
|
+
Legion::Data::DatabaseLock.with_claim(key: { kind: 'audit', stream_id: chain_id }) do
|
|
27
|
+
parent_hash = latest_chain_hash(chain_id: chain_id)
|
|
28
28
|
ts = truncate_to_us(Time.now)
|
|
29
29
|
ch = compute_chain_hash(parent_hash, content_hash, ts, content_type)
|
|
30
30
|
sig = sign ? sign_record(ch) : nil
|
|
31
31
|
meta_json = metadata.empty? ? nil : Legion::JSON.dump(metadata)
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
record = Legion::Data::Model::AuditRecord.create(
|
|
34
34
|
chain_id: chain_id,
|
|
35
35
|
content_type: content_type,
|
|
36
36
|
content_hash: content_hash,
|
|
@@ -41,23 +41,23 @@ module Legion
|
|
|
41
41
|
created_at: ts
|
|
42
42
|
)
|
|
43
43
|
|
|
44
|
-
log.debug "AuditRecord append: chain=#{chain_id} type=#{content_type} id=#{id}"
|
|
45
|
-
{ id: id, chain_id: chain_id, chain_hash: ch, parent_hash: parent_hash }
|
|
44
|
+
log.debug "AuditRecord append: chain=#{chain_id} type=#{content_type} id=#{record.id}"
|
|
45
|
+
{ id: record.id, chain_id: chain_id, chain_hash: ch, parent_hash: parent_hash }
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Walk all records in
|
|
49
|
+
# Walk all records in append order and verify that
|
|
50
50
|
# each record's stored chain_hash matches a freshly computed one.
|
|
51
51
|
#
|
|
52
52
|
# @param chain_id [String]
|
|
53
53
|
# @return [Hash] { valid: Boolean, length: Integer, broken_at: Integer? }
|
|
54
54
|
def verify(chain_id:)
|
|
55
|
-
return { valid: false, error: 'db unavailable' } unless
|
|
55
|
+
return { valid: false, error: 'db unavailable' } unless Legion::Data.connected?
|
|
56
56
|
|
|
57
|
-
records = Legion::Data
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
records = Legion::Data::Model::AuditRecord
|
|
58
|
+
.where(chain_id: chain_id)
|
|
59
|
+
.order(:id)
|
|
60
|
+
.all
|
|
61
61
|
|
|
62
62
|
prev_hash = GENESIS_HASH
|
|
63
63
|
records.each do |r|
|
|
@@ -84,11 +84,11 @@ module Legion
|
|
|
84
84
|
# @param since [Time, nil] optional lower bound on created_at
|
|
85
85
|
# @param limit [Integer]
|
|
86
86
|
def walk(chain_id:, since: nil, limit: 1000)
|
|
87
|
-
return [] unless
|
|
87
|
+
return [] unless Legion::Data.connected?
|
|
88
88
|
|
|
89
|
-
ds = Legion::Data.
|
|
89
|
+
ds = Legion::Data::Model::AuditRecord.where(chain_id: chain_id)
|
|
90
90
|
ds = ds.where { created_at >= since } if since
|
|
91
|
-
ds.order(:
|
|
91
|
+
ds.order(:id).limit(limit).all.map { |r| deserialize(r) }
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# Return records filtered by content_type across all chains.
|
|
@@ -97,9 +97,9 @@ module Legion
|
|
|
97
97
|
# @param since [Time, nil]
|
|
98
98
|
# @param limit [Integer]
|
|
99
99
|
def query_by_type(content_type:, since: nil, limit: 100)
|
|
100
|
-
return [] unless
|
|
100
|
+
return [] unless Legion::Data.connected?
|
|
101
101
|
|
|
102
|
-
ds = Legion::Data.
|
|
102
|
+
ds = Legion::Data::Model::AuditRecord.where(content_type: content_type)
|
|
103
103
|
ds = ds.where { created_at >= since } if since
|
|
104
104
|
ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |r| deserialize(r) }
|
|
105
105
|
end
|
|
@@ -135,12 +135,13 @@ module Legion
|
|
|
135
135
|
::Time.at(Rational(us, 1_000_000))
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
def latest_chain_hash(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
def latest_chain_hash(chain_id:)
|
|
139
|
+
records = Legion::Data::Model::AuditRecord
|
|
140
|
+
.select(:chain_hash)
|
|
141
|
+
.where(chain_id: chain_id)
|
|
142
|
+
.order(Sequel.desc(:id))
|
|
143
|
+
records = records.for_update unless Legion::Data::Model::AuditRecord.db.database_type == :sqlite
|
|
144
|
+
last = records.first
|
|
144
145
|
last ? last[:chain_hash] : GENESIS_HASH
|
|
145
146
|
end
|
|
146
147
|
|
|
@@ -166,13 +167,6 @@ module Legion
|
|
|
166
167
|
created_at: row[:created_at]
|
|
167
168
|
}
|
|
168
169
|
end
|
|
169
|
-
|
|
170
|
-
def db_ready?
|
|
171
|
-
defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:audit_records)
|
|
172
|
-
rescue StandardError => e
|
|
173
|
-
handle_exception(e, level: :warn, handled: true, operation: :audit_record_db_ready?)
|
|
174
|
-
false
|
|
175
|
-
end
|
|
176
170
|
end
|
|
177
171
|
end
|
|
178
172
|
end
|
|
@@ -168,14 +168,14 @@ module Legion
|
|
|
168
168
|
attr_accessor :sequel
|
|
169
169
|
|
|
170
170
|
def adapter
|
|
171
|
-
@adapter
|
|
171
|
+
@adapter || Legion::Settings[:data][:adapter].to_sym
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def setup
|
|
175
175
|
Legion::Settings[:data][:connected] = false
|
|
176
176
|
@sequel&.disconnect
|
|
177
177
|
@sequel = nil
|
|
178
|
-
@adapter = Legion::Settings[:data][:adapter]
|
|
178
|
+
@adapter = Legion::Settings[:data][:adapter].to_sym
|
|
179
179
|
opts = sequel_opts
|
|
180
180
|
log.info("Legion::Data::Connection setup adapter=#{adapter}")
|
|
181
181
|
@fallback_active = false
|
|
@@ -189,7 +189,7 @@ module Legion
|
|
|
189
189
|
rescue UnresolvedCredentialError => e
|
|
190
190
|
handle_exception(e, level: :fatal, handled: false, operation: :data_connect)
|
|
191
191
|
raise
|
|
192
|
-
rescue
|
|
192
|
+
rescue StandardError => e
|
|
193
193
|
raise unless dev_fallback?
|
|
194
194
|
|
|
195
195
|
log.error("Legion::Data FALLING BACK TO SQLITE — #{attempted_adapter} network DB connection failed: #{e.message}")
|
|
@@ -220,7 +220,7 @@ module Legion
|
|
|
220
220
|
adapter: adapter,
|
|
221
221
|
connected: Legion::Settings[:data][:connected],
|
|
222
222
|
fallback_active: @fallback_active || false,
|
|
223
|
-
configured_adapter: Legion::Settings[:data][:adapter]
|
|
223
|
+
configured_adapter: Legion::Settings[:data][:adapter].to_sym,
|
|
224
224
|
sequel_alive: (begin
|
|
225
225
|
!@sequel&.test_connection.nil?
|
|
226
226
|
rescue StandardError => e
|
|
@@ -287,16 +287,14 @@ module Legion
|
|
|
287
287
|
end
|
|
288
288
|
|
|
289
289
|
def shutdown
|
|
290
|
-
|
|
291
|
-
timeout = Legion::Settings[:data][:shutdown_timeout]
|
|
292
|
-
force_disconnect_pool(timeout: timeout)
|
|
293
|
-
end
|
|
290
|
+
@sequel&.disconnect
|
|
294
291
|
@query_file_logger&.close
|
|
295
292
|
@query_file_logger = nil
|
|
296
293
|
@fallback_active = false
|
|
297
294
|
@replica_servers = []
|
|
298
295
|
Legion::Settings[:data][:connected] = false
|
|
299
296
|
log.info 'Legion::Data connection closed'
|
|
297
|
+
true
|
|
300
298
|
end
|
|
301
299
|
|
|
302
300
|
def reconnect_with_fresh_creds
|
|
@@ -317,6 +315,7 @@ module Legion
|
|
|
317
315
|
old_user = @sequel.opts[:user]
|
|
318
316
|
@sequel.opts[:user] = new_user
|
|
319
317
|
@sequel.opts[:password] = new_pass
|
|
318
|
+
Legion::Data.reset_privileges!
|
|
320
319
|
|
|
321
320
|
@sequel.disconnect
|
|
322
321
|
expire_all_pooled_connections
|
|
@@ -354,7 +353,7 @@ module Legion
|
|
|
354
353
|
return creds if adapter == :sqlite
|
|
355
354
|
return creds unless defined?(Legion::Crypt::TLS)
|
|
356
355
|
|
|
357
|
-
tls_settings =
|
|
356
|
+
tls_settings = Legion::Settings[:data][:tls]
|
|
358
357
|
return creds unless tls_settings[:enabled] == true
|
|
359
358
|
|
|
360
359
|
tls = Legion::Crypt::TLS.resolve(tls_settings, port: port)
|
|
@@ -402,15 +401,6 @@ module Legion
|
|
|
402
401
|
|
|
403
402
|
private
|
|
404
403
|
|
|
405
|
-
def data_tls_settings
|
|
406
|
-
return {} unless defined?(Legion::Settings)
|
|
407
|
-
|
|
408
|
-
Legion::Settings[:data][:tls] || {}
|
|
409
|
-
rescue StandardError => e
|
|
410
|
-
handle_exception(e, level: :warn, handled: true, operation: :data_tls_settings)
|
|
411
|
-
{}
|
|
412
|
-
end
|
|
413
|
-
|
|
414
404
|
def log_connection_info
|
|
415
405
|
if adapter == :sqlite
|
|
416
406
|
log.info "Connected to SQLite at #{sqlite_path}"
|
|
@@ -441,30 +431,8 @@ module Legion
|
|
|
441
431
|
data_settings[:dev_mode] == true && data_settings[:dev_fallback] != false
|
|
442
432
|
end
|
|
443
433
|
|
|
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
434
|
def sqlite_path
|
|
467
|
-
path = Legion::Settings[:data][:creds][:database]
|
|
435
|
+
path = Legion::Settings[:data][:creds][:database]
|
|
468
436
|
return path if File.absolute_path?(path)
|
|
469
437
|
|
|
470
438
|
base_dir = File.expand_path('~/.legionio/data')
|
|
@@ -610,7 +578,7 @@ module Legion
|
|
|
610
578
|
stats[pragma.to_sym] = val unless val.nil?
|
|
611
579
|
end
|
|
612
580
|
|
|
613
|
-
db_path = Legion::Settings[:data][:creds][:database]
|
|
581
|
+
db_path = Legion::Settings[:data][:creds][:database]
|
|
614
582
|
stats[:file_size] = File.size(db_path) if File.exist?(db_path)
|
|
615
583
|
stats[:database_size_bytes] = (stats[:page_size].to_i * stats[:page_count].to_i) if stats[:page_size] && stats[:page_count]
|
|
616
584
|
stats
|
|
@@ -698,15 +666,14 @@ module Legion
|
|
|
698
666
|
|
|
699
667
|
install_auth_failure_hook
|
|
700
668
|
rescue StandardError => e
|
|
701
|
-
handle_exception(e, level: :
|
|
702
|
-
raise
|
|
669
|
+
handle_exception(e, level: :warn, handled: true, operation: :configure_extensions, adapter: adapter)
|
|
670
|
+
raise e
|
|
703
671
|
end
|
|
704
672
|
|
|
705
673
|
def install_auth_failure_hook
|
|
706
674
|
Legion::Data::AuthFailureHandler.install(@sequel)
|
|
707
675
|
rescue StandardError => e
|
|
708
|
-
handle_exception(e, level: :
|
|
709
|
-
raise
|
|
676
|
+
handle_exception(e, level: :warn, handled: true, operation: :install_auth_failure_hook)
|
|
710
677
|
end
|
|
711
678
|
|
|
712
679
|
def build_data_logger
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Data
|
|
5
|
+
module DatabaseLock
|
|
6
|
+
def self.with_claim(key:)
|
|
7
|
+
database = Legion::Data::Model::AppendClaim.db
|
|
8
|
+
options = { savepoint: true }
|
|
9
|
+
options[:mode] = :immediate if database.database_type == :sqlite
|
|
10
|
+
database.transaction(**options) do
|
|
11
|
+
claim = Legion::Data::Model::AppendClaim.where(key)
|
|
12
|
+
# InnoDB needs exclusive duplicate-key locks to avoid shared-lock upgrade deadlocks.
|
|
13
|
+
claim = claim.on_duplicate_key_update(*key.keys) if database.database_type == :mysql
|
|
14
|
+
claim.insert(key)
|
|
15
|
+
result = yield
|
|
16
|
+
# The unique-key lock lasts until commit; historical keys need no retained rows.
|
|
17
|
+
claim.delete
|
|
18
|
+
result
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
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
|
|
@@ -20,12 +21,11 @@ module Legion
|
|
|
20
21
|
def append(stream:, type:, data: {}, metadata: {})
|
|
21
22
|
return { error: 'db unavailable' } unless db_ready?
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.first
|
|
24
|
+
database = Legion::Data::Model::GovernanceEvent.db
|
|
25
|
+
Legion::Data::DatabaseLock.with_claim(key: { kind: 'event', stream_id: stream }) do
|
|
26
|
+
records = Legion::Data::Model::GovernanceEvent.where(stream_id: stream).order(Sequel.desc(:sequence_number))
|
|
27
|
+
records = records.for_update unless database.database_type == :sqlite
|
|
28
|
+
last = records.first
|
|
29
29
|
|
|
30
30
|
seq = (last&.[](:sequence_number) || 0) + 1
|
|
31
31
|
prev_hash = last&.[](:event_hash) || ('0' * 64)
|
|
@@ -34,7 +34,7 @@ module Legion
|
|
|
34
34
|
metadata_json = Legion::JSON.dump(metadata)
|
|
35
35
|
event_hash = compute_hash(stream, seq, type, data_json, metadata_json, prev_hash)
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Legion::Data::Model::GovernanceEvent.create(
|
|
38
38
|
stream_id: stream,
|
|
39
39
|
event_type: type,
|
|
40
40
|
sequence_number: seq,
|
|
@@ -53,7 +53,7 @@ module Legion
|
|
|
53
53
|
def read_stream(stream, since: nil)
|
|
54
54
|
return [] unless db_ready?
|
|
55
55
|
|
|
56
|
-
ds = Legion::Data.
|
|
56
|
+
ds = Legion::Data::Model::GovernanceEvent.where(stream_id: stream)
|
|
57
57
|
ds = ds.where { created_at >= since } if since
|
|
58
58
|
ds.order(:sequence_number).all.map { |e| deserialize(e) }
|
|
59
59
|
end
|
|
@@ -61,7 +61,7 @@ module Legion
|
|
|
61
61
|
def read_by_type(type, since: nil, limit: 100)
|
|
62
62
|
return [] unless db_ready?
|
|
63
63
|
|
|
64
|
-
ds = Legion::Data.
|
|
64
|
+
ds = Legion::Data::Model::GovernanceEvent.where(event_type: type)
|
|
65
65
|
ds = ds.where { created_at >= since } if since
|
|
66
66
|
ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |e| deserialize(e) }
|
|
67
67
|
end
|
|
@@ -69,10 +69,10 @@ module Legion
|
|
|
69
69
|
def verify_chain(stream)
|
|
70
70
|
return { valid: false, error: 'db unavailable' } unless db_ready?
|
|
71
71
|
|
|
72
|
-
events = Legion::Data
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
events = Legion::Data::Model::GovernanceEvent
|
|
73
|
+
.where(stream_id: stream)
|
|
74
|
+
.order(:sequence_number)
|
|
75
|
+
.all
|
|
76
76
|
|
|
77
77
|
prev_hash = '0' * 64
|
|
78
78
|
legacy_hashes = 0
|
|
@@ -128,7 +128,7 @@ module Legion
|
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
def db_ready?
|
|
131
|
-
|
|
131
|
+
Legion::Data.connected? && Legion::Data::Model::GovernanceEvent.db.table_exists?(:governance_events)
|
|
132
132
|
rescue StandardError => e
|
|
133
133
|
handle_exception(e, level: :warn, handled: true, operation: :event_store_db_ready?)
|
|
134
134
|
false
|
data/lib/legion/data/extract.rb
CHANGED
|
@@ -102,16 +102,14 @@ module Legion
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def persist_step_timings(extract_id, timings)
|
|
105
|
-
return unless
|
|
105
|
+
return unless Legion::Data.connected?
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
existing_steps = Legion::Data::Model::ExtractStepTiming.where(extract_id: extract_id).select_map(:name)
|
|
108
|
+
timings.each do |timing|
|
|
109
|
+
next if existing_steps.include?(timing[:name])
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
rows = timings.reject { |timing| existing_steps.include?(timing[:name]) }.map do |timing|
|
|
112
|
-
timing.merge(extract_id: extract_id)
|
|
111
|
+
Legion::Data::Model::ExtractStepTiming.create(timing.merge(extract_id: extract_id))
|
|
113
112
|
end
|
|
114
|
-
connection[:extract_step_timings].multi_insert(rows) unless rows.empty?
|
|
115
113
|
rescue StandardError => e
|
|
116
114
|
handle_exception(e, level: :warn, handled: true, operation: :persist_extract_step_timings,
|
|
117
115
|
extract_id: extract_id)
|
data/lib/legion/data/helper.rb
CHANGED
data/lib/legion/data/local.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Legion
|
|
|
17
17
|
def setup(database: nil, **)
|
|
18
18
|
return if @connected
|
|
19
19
|
|
|
20
|
-
db_file = database ||
|
|
20
|
+
db_file = database || Legion::Settings[:data][:local][:database]
|
|
21
21
|
unless File.absolute_path?(db_file)
|
|
22
22
|
base_dir = File.expand_path('~/.legionio')
|
|
23
23
|
FileUtils.mkdir_p(base_dir)
|
|
@@ -26,19 +26,19 @@ module Legion
|
|
|
26
26
|
@db_path = db_file
|
|
27
27
|
|
|
28
28
|
sqlite_defaults = Legion::Data::Connection::ADAPTER_DEFAULTS.fetch(:sqlite, {})
|
|
29
|
-
data =
|
|
29
|
+
data = Legion::Settings[:data]
|
|
30
30
|
opts = { adapter: :sqlite, database: db_file }
|
|
31
31
|
Legion::Data::Connection::ADAPTER_KEYS.fetch(:sqlite, []).each do |key|
|
|
32
32
|
val = data.key?(key) && !data[key].nil? ? data[key] : sqlite_defaults[key]
|
|
33
33
|
opts[key] = val unless val.nil?
|
|
34
34
|
end
|
|
35
|
-
opts[:timeout] = data[:timeout]
|
|
35
|
+
opts[:timeout] = data[:timeout].nil? ? 30_000 : data[:timeout]
|
|
36
36
|
opts[:after_connect] = proc do |connection|
|
|
37
37
|
Legion::Data::Connection.configure_sqlite_busy_handler(connection: connection, timeout: opts[:timeout])
|
|
38
38
|
connection.execute_batch('PRAGMA synchronous=NORMAL; PRAGMA cache_size=-20000; PRAGMA mmap_size=268435456')
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
if
|
|
41
|
+
if Legion::Settings[:data][:local][:query_log]
|
|
42
42
|
log_path = File.join(Legion::Data::Connection::QUERY_LOG_DIR, 'data-local-query.log')
|
|
43
43
|
@query_file_logger = Legion::Data::Connection::QueryFileLogger.new(log_path)
|
|
44
44
|
opts[:logger] = @query_file_logger
|
|
@@ -99,7 +99,7 @@ module Legion
|
|
|
99
99
|
connected: true,
|
|
100
100
|
adapter: :sqlite,
|
|
101
101
|
path: @db_path,
|
|
102
|
-
query_log:
|
|
102
|
+
query_log: Legion::Settings[:data][:local][:query_log],
|
|
103
103
|
query_log_path: @query_file_logger&.path,
|
|
104
104
|
registered_migrations: registered_migrations.keys
|
|
105
105
|
}
|
|
@@ -135,7 +135,7 @@ module Legion
|
|
|
135
135
|
private
|
|
136
136
|
|
|
137
137
|
def run_migrations
|
|
138
|
-
return unless
|
|
138
|
+
return unless Legion::Settings[:data][:local][:migrations][:auto_migrate]
|
|
139
139
|
|
|
140
140
|
registered_migrations.each do |name, path|
|
|
141
141
|
run_single_migration(name, path)
|
|
@@ -143,7 +143,7 @@ module Legion
|
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
def run_single_migration(name, path)
|
|
146
|
-
return unless
|
|
146
|
+
return unless Legion::Settings[:data][:local][:migrations][:auto_migrate]
|
|
147
147
|
return unless File.directory?(path)
|
|
148
148
|
|
|
149
149
|
table = :"schema_migrations_#{name}"
|
|
@@ -153,12 +153,6 @@ module Legion
|
|
|
153
153
|
raise
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
-
def local_settings
|
|
157
|
-
return {} unless defined?(Legion::Settings)
|
|
158
|
-
|
|
159
|
-
Legion::Settings[:data]&.dig(:local) || {}
|
|
160
|
-
end
|
|
161
|
-
|
|
162
156
|
def build_local_logger
|
|
163
157
|
tagged = if defined?(Legion::Logging::TaggedLogger) && respond_to?(:tagged_logger_settings, true)
|
|
164
158
|
Legion::Logging::TaggedLogger.new(
|
|
@@ -181,17 +175,11 @@ module Legion
|
|
|
181
175
|
end
|
|
182
176
|
|
|
183
177
|
def resolved_sql_log_level
|
|
184
|
-
|
|
185
|
-
rescue StandardError => e
|
|
186
|
-
handle_exception(e, level: :warn, handled: true, operation: :resolved_sql_log_level)
|
|
187
|
-
:debug
|
|
178
|
+
Legion::Settings[:data][:local].fetch(:sql_log_level, Legion::Settings[:data][:sql_log_level]).to_sym
|
|
188
179
|
end
|
|
189
180
|
|
|
190
181
|
def resolved_log_warn_duration
|
|
191
|
-
|
|
192
|
-
rescue StandardError => e
|
|
193
|
-
handle_exception(e, level: :warn, handled: true, operation: :resolved_log_warn_duration)
|
|
194
|
-
nil
|
|
182
|
+
Legion::Settings[:data][:local].fetch(:log_warn_duration, Legion::Settings[:data][:log_warn_duration])
|
|
195
183
|
end
|
|
196
184
|
end
|
|
197
185
|
end
|