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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -2
  3. data/AGENTS.md +35 -2
  4. data/CHANGELOG.md +109 -0
  5. data/CLAUDE.md +1 -30
  6. data/Gemfile +3 -0
  7. data/README.md +134 -17
  8. data/legion-data.gemspec +1 -1
  9. data/lib/legion/data/archival/policy.rb +1 -16
  10. data/lib/legion/data/archival.rb +49 -50
  11. data/lib/legion/data/archiver.rb +2 -1
  12. data/lib/legion/data/audit_log_hash_chain.rb +3 -1
  13. data/lib/legion/data/audit_record.rb +25 -31
  14. data/lib/legion/data/connection.rb +13 -46
  15. data/lib/legion/data/database_lock.rb +23 -0
  16. data/lib/legion/data/event_store.rb +14 -14
  17. data/lib/legion/data/extract.rb +5 -7
  18. data/lib/legion/data/helper.rb +1 -1
  19. data/lib/legion/data/local.rb +9 -21
  20. data/lib/legion/data/migration.rb +24 -0
  21. data/lib/legion/data/migrations/137_preserve_task_archive_fields.rb +37 -0
  22. data/lib/legion/data/migrations/138_add_append_claims.rb +11 -0
  23. data/lib/legion/data/migrations/139_create_task_logs.rb +27 -0
  24. data/lib/legion/data/model.rb +2 -2
  25. data/lib/legion/data/models/append_claim.rb +10 -0
  26. data/lib/legion/data/models/audit_log.rb +34 -2
  27. data/lib/legion/data/models/data_archive.rb +10 -0
  28. data/lib/legion/data/models/governance_event.rb +10 -0
  29. data/lib/legion/data/models/task.rb +5 -5
  30. data/lib/legion/data/models/task_log.rb +16 -0
  31. data/lib/legion/data/retention.rb +42 -11
  32. data/lib/legion/data/settings.rb +6 -9
  33. data/lib/legion/data/spool.rb +36 -16
  34. data/lib/legion/data/storage_tiers.rb +26 -20
  35. data/lib/legion/data/task/status.rb +36 -0
  36. data/lib/legion/data/version.rb +1 -1
  37. data/lib/legion/data.rb +47 -23
  38. metadata +11 -3
@@ -16,6 +16,30 @@ module Legion
16
16
  return
17
17
  end
18
18
 
19
+ if connection.database_type == :postgres
20
+ role_state = connection.fetch(<<~SQL).first
21
+ SELECT session_user,
22
+ current_user,
23
+ current_setting('role') AS active_role,
24
+ to_regrole('legionio_migrator')::text AS migrator_role,
25
+ CASE
26
+ WHEN to_regrole('legionio_migrator') IS NULL THEN NULL
27
+ ELSE pg_has_role(session_user, 'legionio_migrator', 'MEMBER')
28
+ END AS migrator_member,
29
+ CASE
30
+ WHEN to_regrole('legionio_migrator') IS NULL THEN NULL
31
+ ELSE pg_has_role(session_user, 'legionio_migrator', 'SET')
32
+ END AS migrator_set,
33
+ has_schema_privilege(session_user, 'public', 'CREATE') AS session_create,
34
+ has_schema_privilege(current_user, 'public', 'CREATE') AS current_create,
35
+ CASE
36
+ WHEN to_regrole('legionio_migrator') IS NULL THEN NULL
37
+ ELSE has_schema_privilege('legionio_migrator', 'public', 'CREATE')
38
+ END AS migrator_create
39
+ SQL
40
+ Legion::Logging.unknown "migration role state=#{role_state.inspect}"
41
+ end
42
+
19
43
  Legion::Settings[:data][:migrations][:version] = Sequel::Migrator.run(connection, path, **)
20
44
  log.info("Legion::Data::Migration ran successfully to version #{Legion::Settings[:data][:migrations][:version]}")
21
45
  Legion::Settings[:data][:migrations][:ran] = true
@@ -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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table(:task_logs) do
6
+ primary_key :id
7
+ String :uuid, size: 36, null: false, unique: true
8
+ String :access_scope, size: 20, null: false, default: 'global', index: true
9
+ foreign_key :identity_principal_id, :identity_principals, null: true, on_delete: :set_null, on_update: :cascade
10
+ foreign_key :identity_id, :identities, null: true, on_delete: :set_null, on_update: :cascade
11
+ String :identity_canonical_name, size: 255, null: true, index: true
12
+ foreign_key :task_id, :tasks, null: true, on_delete: :set_null, on_update: :cascade
13
+ foreign_key :node_id, :nodes, null: true, on_delete: :set_null, on_update: :cascade
14
+ foreign_key :function_id, :functions, null: true, on_delete: :set_null, on_update: :cascade
15
+ String :entry, text: true, null: false
16
+ DateTime :created, null: false, default: Sequel::CURRENT_TIMESTAMP
17
+ DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
18
+ DateTime :inserted_at, null: false, default: Sequel::CURRENT_TIMESTAMP
19
+ DateTime :updated_at, null: true
20
+ index :identity_principal_id
21
+ index :task_id
22
+ index :node_id
23
+ index :function_id
24
+ index :created
25
+ end
26
+ end
27
+ end
@@ -11,9 +11,9 @@ module Legion
11
11
  attr_reader :loaded_models
12
12
 
13
13
  def models
14
- %w[extension function relationship chain task runner node setting digital_worker
14
+ %w[extension function relationship chain task task_log runner node setting digital_worker
15
15
  apollo_entry apollo_relation apollo_expertise apollo_access_log audit_log
16
- audit_record extract_step_timing
16
+ audit_record governance_event append_claim data_archive extract_step_timing
17
17
  identity/identity identity/principal identity/providers identity/group
18
18
  identity/group_memberships identity/audit_log
19
19
  apollo/entries apollo/relation apollo/access_log apollo/expertise
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Data
5
+ module Model
6
+ class AppendClaim < Sequel::Model(:append_claims)
7
+ end
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Data
5
+ module Model
6
+ class DataArchive < Sequel::Model(:data_archive)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Data
5
+ module Model
6
+ class GovernanceEvent < Sequel::Model(:governance_events)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -2,15 +2,15 @@
2
2
 
3
3
  require 'digest'
4
4
  require 'legion/json'
5
+ require 'legion/data/task/status'
5
6
  require 'time'
6
7
 
7
8
  module Legion
8
9
  module Data
9
10
  module Model
10
11
  class Task < Sequel::Model
11
- TERMINAL_STATUSES = %w[
12
- completed complete failed error cancelled canceled timeout timed_out
13
- ].freeze
12
+ TERMINAL_STATUSES = (Legion::Data::Task::Status::TERMINAL_STATUSES +
13
+ Legion::Data::Task::Status::LEGACY_TERMINAL_STATUSES).freeze
14
14
 
15
15
  many_to_one :function
16
16
  many_to_one :relationship
@@ -60,9 +60,9 @@ module Legion
60
60
  when Array
61
61
  value.map { |item| canonical_payload(item) }
62
62
  when Time
63
- value.utc.iso8601(6)
63
+ value.getutc.iso8601(6)
64
64
  when DateTime
65
- value.to_time.utc.iso8601(6)
65
+ value.to_time.getutc.iso8601(6)
66
66
  else
67
67
  value
68
68
  end
@@ -1,11 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Legion
4
6
  module Data
5
7
  module Model
6
8
  class TaskLog < Sequel::Model
7
9
  many_to_one :task
8
10
  many_to_one :node
11
+ many_to_one :function
12
+
13
+ def self.insert(values)
14
+ create(values).id
15
+ end
16
+
17
+ def before_create
18
+ self[:uuid] ||= SecureRandom.uuid
19
+ self[:created] ||= Time.now
20
+ self[:created] = self[:created].getlocal if self[:created].is_a?(Time)
21
+ self[:created_at] ||= self[:created]
22
+ self[:created_at] = self[:created_at].getlocal if self[:created_at].is_a?(Time)
23
+ super
24
+ end
9
25
  end
10
26
  end
11
27
  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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/settings'
4
5
 
5
6
  module Legion
6
7
  module Data
@@ -76,10 +77,6 @@ module Legion
76
77
  keepalives_count: 3,
77
78
  tcp_user_timeout: 15_000,
78
79
 
79
- # Shutdown: max seconds to wait for checked-out connections to return before
80
- # force-disconnecting them.
81
- shutdown_timeout: 5,
82
-
83
80
  # Adapter-specific (nil = use adapter built-in default)
84
81
  connect_timeout: nil,
85
82
  read_timeout: nil,
@@ -95,6 +92,7 @@ module Legion
95
92
 
96
93
  # Grouped settings
97
94
  creds: creds,
95
+ tls: { enabled: false },
98
96
  cache: cache,
99
97
  migrations: migrations,
100
98
  models: models,
@@ -141,7 +139,10 @@ module Legion
141
139
  def self.archival
142
140
  {
143
141
  retention_days: 90,
142
+ warm_after_days: 7,
143
+ cold_after_days: 90,
144
144
  batch_size: 1000,
145
+ tables: %w[tasks metering_records],
145
146
  storage_backend: nil
146
147
  }
147
148
  end
@@ -158,8 +159,4 @@ module Legion
158
159
  end
159
160
  end
160
161
 
161
- begin
162
- Legion::Settings.merge_settings('data', Legion::Data::Settings.default) if Legion.const_defined?('Settings', false)
163
- rescue StandardError => e
164
- Legion::Data::Settings.handle_exception(e, level: :fatal, operation: :merge_settings)
165
- end
162
+ Legion::Settings.register_library(:data, Legion::Data::Settings.default)
@@ -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
@@ -10,56 +10,62 @@ module Legion
10
10
  class << self
11
11
  include Legion::Logging::Helper
12
12
 
13
- def archive_to_warm(table:, age_days: 90, batch_size: 1000)
14
- return { archived: 0, reason: 'no_connection' } unless Legion::Data.connection
15
- return { archived: 0, reason: 'no_archive_table' } unless Legion::Data.connection.table_exists?(:data_archive)
13
+ def archive_to_warm(model:, age_days: 90, batch_size: 1000)
14
+ return { archived: 0, reason: 'no_connection' } unless Legion::Data.connected?
15
+
16
+ table = model.table_name
17
+ db = model.db
18
+ raise ArgumentError, 'source model must use the DataArchive database' unless db.equal?(Legion::Data::Model::DataArchive.db)
16
19
 
17
20
  cutoff = Time.now - (age_days * 86_400)
18
- records = Legion::Data.connection[table].where { created_at < cutoff }.limit(batch_size).all
19
- return { archived: 0 } if records.empty?
21
+ archived = db.transaction(savepoint: true, **(db.database_type == :sqlite ? { mode: :immediate } : {})) do
22
+ candidates = model.where { created_at < cutoff }.order(:id).limit(batch_size)
23
+ candidates = candidates.for_update unless db.database_type == :sqlite
24
+ records = candidates.all
25
+ next 0 if records.empty?
20
26
 
21
- Legion::Data.connection.transaction do
22
27
  records.each do |record|
23
- Legion::Data.connection[:data_archive].insert(
28
+ Legion::Data::Model::DataArchive.create(
24
29
  source_table: table.to_s, source_id: record[:id],
25
- data: Legion::JSON.dump(record),
30
+ data: Legion::JSON.dump(record.values),
26
31
  tier: TIERS[:warm],
27
32
  archived_at: Time.now.utc
28
33
  )
29
34
  end
30
35
 
31
36
  ids = records.map { |r| r[:id] }
32
- Legion::Data.connection[table].where(id: ids).delete
37
+ model.where(id: ids).delete
33
38
  end
39
+ return { archived: 0 } if archived.zero?
34
40
 
35
- log.info "Archived #{records.size} row(s) from #{table} to warm tier"
36
- { archived: records.size, table: table.to_s }
41
+ log.info "Archived #{archived} row(s) from #{table} to warm tier"
42
+ { archived: archived, table: table.to_s }
37
43
  rescue StandardError => e
38
44
  handle_exception(e, level: :error, handled: false, operation: :archive_to_warm, table: table, age_days: age_days, batch_size: batch_size)
39
45
  raise
40
46
  end
41
47
 
42
48
  def export_to_cold(age_days: 365, batch_size: 5000)
43
- return { exported: 0 } unless Legion::Data.connection&.table_exists?(:data_archive)
49
+ return { exported: 0 } unless Legion::Data.connected?
44
50
 
45
51
  cutoff = Time.now - (age_days * 86_400)
46
- records = Legion::Data.connection[:data_archive]
47
- .where(tier: TIERS[:warm])
48
- .where { archived_at < cutoff }
49
- .limit(batch_size).all
52
+ records = Legion::Data::Model::DataArchive
53
+ .where(tier: TIERS[:warm])
54
+ .where { archived_at < cutoff }
55
+ .limit(batch_size).all
50
56
  return { exported: 0 } if records.empty?
51
57
 
52
58
  ids = records.map { |r| r[:id] }
53
- Legion::Data.connection[:data_archive].where(id: ids).update(tier: TIERS[:cold])
59
+ Legion::Data::Model::DataArchive.where(id: ids).update(tier: TIERS[:cold])
54
60
  log.info "Exported #{records.size} row(s) to cold tier"
55
- { exported: records.size, data: records }
61
+ { exported: records.size, data: records.map(&:values) }
56
62
  rescue StandardError => e
57
63
  handle_exception(e, level: :error, handled: false, operation: :export_to_cold, age_days: age_days, batch_size: batch_size)
58
64
  raise
59
65
  end
60
66
 
61
67
  def stats
62
- return {} unless Legion::Data.connection&.table_exists?(:data_archive)
68
+ return {} unless Legion::Data.connected?
63
69
 
64
70
  { warm: count_tier(:warm), cold: count_tier(:cold) }
65
71
  rescue StandardError => e
@@ -70,7 +76,7 @@ module Legion
70
76
  private
71
77
 
72
78
  def count_tier(tier)
73
- Legion::Data.connection[:data_archive].where(tier: TIERS[tier]).count
79
+ Legion::Data::Model::DataArchive.where(tier: TIERS[tier]).count
74
80
  rescue StandardError => e
75
81
  handle_exception(e, level: :warn, handled: true, operation: :storage_tiers_count, tier: tier)
76
82
  0
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Data
5
+ module Task
6
+ module Status
7
+ STATUSES = {
8
+ scheduled: 'task.scheduled', queued: 'task.queued', running: 'task.running', completed: 'task.completed',
9
+ exception: 'task.exception', failed: 'task.failed', skipped: 'task.skipped',
10
+ cancelled: 'task.cancelled', expired: 'task.expired', delayed: 'task.delayed',
11
+ dispatching: 'task.dispatching', multiplied: 'task.multiplied', retry_pending: 'task.retry_pending',
12
+ unknown: 'task.unknown', conditioner_queued: 'conditioner.queued', conditioner_failed: 'conditioner.failed',
13
+ conditioner_exception: 'conditioner.exception', transformer_queued: 'transformer.queued',
14
+ transformer_succeeded: 'transformer.succeeded', transformer_validation_failed: 'transformer.validation_failed',
15
+ transformer_failed: 'transformer.failed', transformer_exception: 'transformer.exception', blocked: 'task.blocked'
16
+ }.freeze
17
+ TERMINAL_STATUSES = STATUSES.values_at(:completed, :failed, :exception, :skipped, :cancelled, :expired, :multiplied,
18
+ :conditioner_failed, :conditioner_exception, :transformer_validation_failed,
19
+ :transformer_failed, :transformer_exception, :unknown, :blocked).freeze
20
+ LEGACY_TERMINAL_STATUSES = %w[completed complete failed error cancelled canceled timeout timed_out exception skipped expired multiplied].freeze
21
+
22
+ module Helper
23
+ def task_status(name:)
24
+ raise ArgumentError, 'Task status name must be a String or Symbol' unless name.is_a?(String) || name.is_a?(Symbol)
25
+
26
+ STATUSES.fetch(name.to_sym) { raise ArgumentError, "Unknown task status name #{name.inspect}" }
27
+ end
28
+
29
+ def task_terminal_status?(status:)
30
+ TERMINAL_STATUSES.include?(status) || LEGACY_TERMINAL_STATUSES.include?(status)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Data
5
- VERSION = '1.10.17'
5
+ VERSION = '1.11.7'
6
6
  end
7
7
  end