legion-data 1.10.9 → 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.
@@ -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
@@ -47,11 +47,17 @@ module Legion
47
47
  end
48
48
 
49
49
  def with_tenant(tenant_id)
50
- previous = current_tenant
51
- assign_tenant(tenant_id)
52
- yield
53
- ensure
54
- previous ? assign_tenant(previous) : reset_tenant
50
+ return yield unless rls_enabled?
51
+
52
+ Legion::Data.connection.synchronize do
53
+ previous = current_tenant
54
+ begin
55
+ assign_tenant(tenant_id)
56
+ yield
57
+ ensure
58
+ previous ? assign_tenant(previous) : reset_tenant
59
+ end
60
+ end
55
61
  end
56
62
  end
57
63
  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.9'
5
+ VERSION = '1.10.28'
6
6
  end
7
7
  end
data/lib/legion/data.rb CHANGED
@@ -48,6 +48,11 @@ module Legion
48
48
  setup_cache
49
49
  setup_local
50
50
  log.info 'Legion::Data setup complete'
51
+ rescue StandardError, LoadError => e
52
+ shutdown
53
+ Legion::Data::Connection.sequel = nil
54
+ handle_exception(e, level: :fatal, handled: false, operation: :setup)
55
+ raise
51
56
  end
52
57
 
53
58
  def setup_local
@@ -62,10 +67,19 @@ module Legion
62
67
 
63
68
  def setup_global
64
69
  connection_setup
70
+ Legion::Settings[:data][:connected] = false
71
+ Legion::Settings[:data][:migrations][:ran] = false
72
+ Legion::Settings[:data][:models][:loaded] = false
65
73
  migrate
66
74
  load_models
67
- rescue StandardError => e
68
- handle_exception(e, level: :fatal, operation: :setup_global)
75
+ Legion::Settings[:data][:connected] = true
76
+ rescue StandardError, LoadError => e
77
+ Legion::Data::Connection.shutdown
78
+ Legion::Data::Connection.sequel = nil
79
+ Legion::Settings[:data][:migrations][:ran] = false
80
+ Legion::Settings[:data][:models][:loaded] = false
81
+ handle_exception(e, level: :fatal, handled: false, operation: :setup_global)
82
+ raise
69
83
  end
70
84
 
71
85
  def setup_cache
@@ -119,36 +133,56 @@ module Legion
119
133
  def can_write?(table_name)
120
134
  return false unless connected?
121
135
 
122
- adapter = Legion::Settings[:data][:adapter]&.to_s
123
- return true if adapter == 'sqlite'
124
-
125
- @write_privileges ||= {}
126
- return @write_privileges[table_name] unless @write_privileges[table_name].nil?
127
-
128
- @write_privileges[table_name] = connection
129
- .fetch("SELECT has_table_privilege(current_user, ?, 'INSERT') AS can", table_name.to_s)
130
- .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
131
156
  rescue StandardError => e
132
157
  handle_exception(e, level: :warn, handled: true, operation: :can_write?, table: table_name)
133
- @write_privileges[table_name] = false if @write_privileges
134
158
  false
135
159
  end
136
160
 
137
161
  def can_read?(table_name)
138
162
  return false unless connected?
139
163
 
140
- adapter = Legion::Settings[:data][:adapter]&.to_s
141
- return true if adapter == 'sqlite'
142
-
143
- @read_privileges ||= {}
144
- return @read_privileges[table_name] unless @read_privileges[table_name].nil?
145
-
146
- @read_privileges[table_name] = connection
147
- .fetch("SELECT has_table_privilege(current_user, ?, 'SELECT') AS can", table_name.to_s)
148
- .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
149
184
  rescue StandardError => e
150
185
  handle_exception(e, level: :warn, handled: true, operation: :can_read?, table: table_name)
151
- @read_privileges[table_name] = false if @read_privileges
152
186
  false
153
187
  end
154
188
 
@@ -191,8 +225,9 @@ module Legion
191
225
 
192
226
  def shutdown
193
227
  Legion::Data::Local.shutdown if defined?(Legion::Data::Local) && Legion::Data::Local.connected?
194
- Legion::Data::Connection.shutdown
195
- log.info 'Legion::Data shutdown complete'
228
+ closed = Legion::Data::Connection.shutdown
229
+ log.info 'Legion::Data shutdown complete' if closed
230
+ closed
196
231
  end
197
232
 
198
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.9
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