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