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
|
@@ -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,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
|
data/lib/legion/data/model.rb
CHANGED
|
@@ -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
|
|
@@ -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 =
|
|
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
|
|
@@ -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 =
|
|
12
|
-
|
|
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.
|
|
63
|
+
value.getutc.iso8601(6)
|
|
64
64
|
when DateTime
|
|
65
|
-
value.to_time.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
123
|
-
|
|
124
|
-
|
|
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
|
data/lib/legion/data/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/legion/data/spool.rb
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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 |
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
14
|
-
return { archived: 0, reason: 'no_connection' } unless Legion::Data.
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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.
|
|
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
|
-
|
|
37
|
+
model.where(id: ids).delete
|
|
33
38
|
end
|
|
39
|
+
return { archived: 0 } if archived.zero?
|
|
34
40
|
|
|
35
|
-
log.info "Archived #{
|
|
36
|
-
{ archived:
|
|
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.
|
|
49
|
+
return { exported: 0 } unless Legion::Data.connected?
|
|
44
50
|
|
|
45
51
|
cutoff = Time.now - (age_days * 86_400)
|
|
46
|
-
records = Legion::Data
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|