legion-data 1.10.9 → 1.10.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +101 -0
- data/README.md +76 -4
- data/lib/legion/data/archival.rb +48 -49
- data/lib/legion/data/archiver.rb +28 -26
- 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 +113 -98
- data/lib/legion/data/connection_pool.rb +83 -0
- data/lib/legion/data/database_lock.rb +22 -0
- data/lib/legion/data/encryption/key_provider.rb +1 -2
- data/lib/legion/data/event_store.rb +6 -5
- data/lib/legion/data/local.rb +16 -9
- 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/rls.rb +11 -5
- data/lib/legion/data/spool.rb +36 -16
- data/lib/legion/data/version.rb +1 -1
- data/lib/legion/data.rb +59 -24
- 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,106 @@
|
|
|
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
|
+
|
|
62
|
+
## [1.10.17] - 2026-10-29
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
- Cache the default encryption key under `nil`, separately from every tenant ID, including the literal `__default__`. Key selection no longer depends on which scope was requested first (#74).
|
|
66
|
+
- Scope database spec cleanup to initialized resources so early setup errors are preserved and original state, including nil connection snapshots, is restored (#83).
|
|
67
|
+
|
|
68
|
+
## [1.10.16] - 2026-10-29
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
- Generate each archive batch UUID before upload and use it in S3, Azure, and local object names and the manifest. Separate runs no longer overwrite earlier archives in the same month. Direct `upload_batch` callers now supply `batch_id:` instead of `batch_n:` (#73).
|
|
72
|
+
- Load the archive compressor's StringIO dependency explicitly.
|
|
73
|
+
|
|
74
|
+
## [1.10.15] - 2026-10-29
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
- Isolate query-log write and close I/O failures from database operations. Full disks no longer turn committed writes into apparent failures or replace the original SQL exception; logging failures remain reported through Legion::Logging (#72).
|
|
78
|
+
|
|
79
|
+
## [1.10.14] - 2026-10-29
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
- Propagate fatal connection configuration, shared migration, model load, and local migration failures. Close failed setup connections, clear readiness, and allow setup to retry rather than continuing with partially initialized databases (#71).
|
|
83
|
+
|
|
84
|
+
## [1.10.13] - 2026-10-29
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
- Build PostgreSQL replica pools before connecting, decode replica URLs into valid Sequel server options, and preserve inherited credentials and session timeouts. Clear replica routing on shutdown and reconfiguration (#70).
|
|
88
|
+
|
|
89
|
+
## [1.10.12] - 2026-10-29
|
|
90
|
+
|
|
91
|
+
### Fixed
|
|
92
|
+
- Keep PostgreSQL tenant setup, scoped queries, and restoration on one checked-out connection. Preserve nested tenant scopes and clean up after exceptions without leaking tenant state to another borrower (#69).
|
|
93
|
+
|
|
94
|
+
## [1.10.11] - 2026-10-29
|
|
95
|
+
|
|
96
|
+
### Fixed
|
|
97
|
+
- Apply PostgreSQL statement and lock timeouts before any connection is opened, including initial, preconnected, and replacement sessions (#68).
|
|
98
|
+
|
|
99
|
+
## [1.10.10] - 2026-10-29
|
|
100
|
+
|
|
101
|
+
### Fixed
|
|
102
|
+
- Release Ruby threads while SQLite waits for write locks, with bounded per-connection busy handlers. Apply local SQLite tuning to every pooled connection and honor explicit `data.timeout` values (#67).
|
|
103
|
+
|
|
3
104
|
## [1.10.9] - 2026-07-31
|
|
4
105
|
### Fixed
|
|
5
106
|
- **Postgres session timeouts**: Apply `statement_timeout` (default 5000ms) and `lock_timeout` (default 2000ms) via `after_connect` hook on every new Postgres connection. Prevents unbounded `SELECT` or lock-wait from hanging the process indefinitely — the root cause of ~1h50m connection wedges.
|
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.
|
|
5
|
+
**Version**: 1.10.18 | **Ruby**: >= 3.4 | **License**: Apache-2.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -23,6 +23,8 @@ Core responsibilities:
|
|
|
23
23
|
|
|
24
24
|
The schema is portable by default across SQLite, MySQL, and PostgreSQL. PostgreSQL-only behavior is isolated to features that need PostgreSQL, such as Apollo vector columns.
|
|
25
25
|
|
|
26
|
+
`Legion::Data.setup` raises on fatal connection configuration, migration, or model-loading failures rather than announcing successful startup. Failed setup closes its database connections and clears readiness; fix the underlying error before retrying. Local migrations registered after startup also propagate failures and leave the local database disconnected until setup succeeds.
|
|
27
|
+
|
|
26
28
|
## Supported Databases
|
|
27
29
|
|
|
28
30
|
| Database | Adapter | Gem | Default |
|
|
@@ -71,7 +73,7 @@ Legion::Data (singleton module)
|
|
|
71
73
|
│ ├── .connection_info # Adapter, liveness, and fallback diagnostics
|
|
72
74
|
│ ├── .fallback_active? # True when dev fallback moved a network DB to SQLite
|
|
73
75
|
│ ├── .stats # Pool metrics, tuning snapshot, adapter-specific DB stats
|
|
74
|
-
│ └── .shutdown #
|
|
76
|
+
│ └── .shutdown # Drain accepted work; return true only after cleanup
|
|
75
77
|
│
|
|
76
78
|
├── Migration # Auto-migration system (97 numbered Sequel DSL migrations)
|
|
77
79
|
│
|
|
@@ -149,6 +151,14 @@ Legion::Data::Connection.connection_info
|
|
|
149
151
|
Legion::Data.shutdown
|
|
150
152
|
```
|
|
151
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
|
+
|
|
152
162
|
### Model Associations
|
|
153
163
|
|
|
154
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.
|
|
@@ -219,16 +229,48 @@ task = Legion::Data::Model::Task.create_idempotent(
|
|
|
219
229
|
)
|
|
220
230
|
```
|
|
221
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
|
+
|
|
222
260
|
### Filesystem Spool (Write Buffer)
|
|
223
261
|
|
|
224
262
|
When the database is unavailable, `Legion::Data::Spool` buffers writes to `~/.legionio/data/spool/` and replays once the connection is restored:
|
|
225
263
|
|
|
226
264
|
```ruby
|
|
227
265
|
spool = Legion::Data::Spool.for(Legion::Extensions::MyLex)
|
|
228
|
-
spool.write({ task_id: SecureRandom.uuid, data: payload })
|
|
229
|
-
spool.
|
|
266
|
+
spool.write(:events, { task_id: SecureRandom.uuid, data: payload })
|
|
267
|
+
spool.flush(:events) { |entry| process(entry) }
|
|
230
268
|
```
|
|
231
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
|
+
|
|
232
274
|
### Row-Level Security (PostgreSQL)
|
|
233
275
|
|
|
234
276
|
`Legion::Data::Rls` provides tenant isolation via PostgreSQL session variables (migration 043):
|
|
@@ -239,6 +281,8 @@ Legion::Data::Rls.with_tenant(tenant_id) do
|
|
|
239
281
|
end
|
|
240
282
|
```
|
|
241
283
|
|
|
284
|
+
The scope holds one pooled connection until the block finishes and its prior tenant state has been restored, including when the block raises. Nested scopes restore the outer tenant. Run scoped database work in the calling thread; newly spawned threads do not inherit its tenant scope.
|
|
285
|
+
|
|
242
286
|
### Permission Checks
|
|
243
287
|
|
|
244
288
|
```ruby
|
|
@@ -247,6 +291,10 @@ Legion::Data.can_read?(:tasks) # => true
|
|
|
247
291
|
Legion::Data.reset_privileges! # clear cached privilege checks
|
|
248
292
|
```
|
|
249
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
|
+
|
|
250
298
|
---
|
|
251
299
|
|
|
252
300
|
## Configuration
|
|
@@ -266,6 +314,8 @@ All settings live under the `data` key. The adapter controls which options apply
|
|
|
266
314
|
}
|
|
267
315
|
```
|
|
268
316
|
|
|
317
|
+
SQLite lock waits use a Ruby busy handler that releases the GVL between retries, so other threads can finish transactions while a writer waits. `data.timeout` sets the wait limit in milliseconds for both databases; when unset, shared SQLite uses 5,000 ms and local SQLite uses 30,000 ms. Local connection-specific tuning is applied to every pooled connection. `PRAGMA busy_timeout` does not report the Ruby handler's deadline.
|
|
318
|
+
|
|
269
319
|
### MySQL
|
|
270
320
|
|
|
271
321
|
```json
|
|
@@ -300,6 +350,8 @@ All settings live under the `data` key. The adapter controls which options apply
|
|
|
300
350
|
}
|
|
301
351
|
```
|
|
302
352
|
|
|
353
|
+
`data.statement_timeout` (default 5,000 ms) and `data.lock_timeout` (default 2,000 ms) are applied before every PostgreSQL connection enters the pool, including startup and replacement connections. Set a value to `null` to leave that server setting unchanged, or `0` to explicitly disable the corresponding PostgreSQL timeout.
|
|
354
|
+
|
|
303
355
|
PostgreSQL with `pgvector` is required for Apollo models:
|
|
304
356
|
|
|
305
357
|
```sql
|
|
@@ -390,6 +442,10 @@ Two independent caching tiers, both disabled by default:
|
|
|
390
442
|
Legion::Data.reload_static_cache
|
|
391
443
|
```
|
|
392
444
|
|
|
445
|
+
### Query File Logging
|
|
446
|
+
|
|
447
|
+
Enable `data.query_log` (or `data.local.query_log`) to write SQL to a dedicated file. Write and close I/O failures, including a full disk, are reported through Legion::Logging without changing the database operation's result or replacing its exception. Opening the configured log file must still succeed during setup.
|
|
448
|
+
|
|
393
449
|
### Read Replicas (PostgreSQL)
|
|
394
450
|
|
|
395
451
|
```json
|
|
@@ -401,6 +457,22 @@ Legion::Data.reload_static_cache
|
|
|
401
457
|
}
|
|
402
458
|
```
|
|
403
459
|
|
|
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`.
|
|
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
|
+
|
|
468
|
+
### Archive Objects
|
|
469
|
+
|
|
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:`.
|
|
471
|
+
|
|
472
|
+
### Encryption Key Cache
|
|
473
|
+
|
|
474
|
+
The default scope (`tenant_id: nil`) and tenant IDs have separate cache entries, including a tenant literally named `__default__`. Restart processes or clear existing key-provider caches when upgrading. This fix does not rewrite existing ciphertext: if that tenant name was used with the old cache, assess records encrypted with the wrong scope's key and re-encrypt them with the intended key. The reader does not fall back to another scope's key.
|
|
475
|
+
|
|
404
476
|
---
|
|
405
477
|
|
|
406
478
|
## Common Fields Standard
|
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)
|
data/lib/legion/data/archiver.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'digest'
|
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require 'json'
|
|
7
7
|
require 'securerandom'
|
|
8
|
+
require 'stringio'
|
|
8
9
|
require 'tmpdir'
|
|
9
10
|
require 'zlib'
|
|
10
11
|
|
|
@@ -47,15 +48,15 @@ module Legion
|
|
|
47
48
|
raise
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
def upload_batch(data:, table:, year:, month:,
|
|
51
|
-
log.info "Archiver storing batch table=#{table} backend=#{backend || :tmpdir} year=#{year} month=#{month} batch=#{
|
|
51
|
+
def upload_batch(data:, table:, year:, month:, batch_id:, backend:)
|
|
52
|
+
log.info "Archiver storing batch table=#{table} backend=#{backend || :tmpdir} year=#{year} month=#{month} batch=#{batch_id}"
|
|
52
53
|
case backend
|
|
53
54
|
when :s3
|
|
54
|
-
upload_s3(data: data, table: table, year: year, month: month,
|
|
55
|
+
upload_s3(data: data, table: table, year: year, month: month, batch_id: batch_id)
|
|
55
56
|
when :azure
|
|
56
|
-
upload_azure(data: data, table: table, year: year, month: month,
|
|
57
|
+
upload_azure(data: data, table: table, year: year, month: month, batch_id: batch_id)
|
|
57
58
|
else
|
|
58
|
-
upload_tmpdir(data: data, table: table, year: year, month: month,
|
|
59
|
+
upload_tmpdir(data: data, table: table, year: year, month: month, batch_id: batch_id)
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
62
|
|
|
@@ -102,7 +103,6 @@ module Legion
|
|
|
102
103
|
table: table,
|
|
103
104
|
cutoff: cutoff,
|
|
104
105
|
batch_size: batch_size,
|
|
105
|
-
batch_n: batches + 1,
|
|
106
106
|
now: now,
|
|
107
107
|
storage_backend: storage_backend
|
|
108
108
|
))
|
|
@@ -114,18 +114,19 @@ module Legion
|
|
|
114
114
|
{ batches: batches, total_rows: total_rows, paths: paths }
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
def archive_batch(conn:, table:, cutoff:, batch_size:,
|
|
117
|
+
def archive_batch(conn:, table:, cutoff:, batch_size:, now:, storage_backend:)
|
|
118
118
|
rows = conn[table].where { created_at < cutoff }.limit(batch_size).all
|
|
119
119
|
return if rows.empty?
|
|
120
120
|
|
|
121
|
+
batch_id = SecureRandom.uuid
|
|
121
122
|
compressed = gzip_compress(serialize_rows(rows))
|
|
122
123
|
path = upload_batch(
|
|
123
|
-
data:
|
|
124
|
-
table:
|
|
125
|
-
year:
|
|
126
|
-
month:
|
|
127
|
-
|
|
128
|
-
backend:
|
|
124
|
+
data: compressed,
|
|
125
|
+
table: table.to_s,
|
|
126
|
+
year: now.year,
|
|
127
|
+
month: now.month,
|
|
128
|
+
batch_id: batch_id,
|
|
129
|
+
backend: storage_backend
|
|
129
130
|
)
|
|
130
131
|
|
|
131
132
|
record_archived_batch(
|
|
@@ -134,16 +135,17 @@ module Legion
|
|
|
134
135
|
rows: rows,
|
|
135
136
|
compressed: compressed,
|
|
136
137
|
path: path,
|
|
138
|
+
batch_id: batch_id,
|
|
137
139
|
now: now
|
|
138
140
|
)
|
|
139
141
|
|
|
140
142
|
{ row_count: rows.size, path: path }
|
|
141
143
|
end
|
|
142
144
|
|
|
143
|
-
def record_archived_batch(conn:, table:, rows:, compressed:, path:, now:)
|
|
145
|
+
def record_archived_batch(conn:, table:, rows:, compressed:, path:, batch_id:, now:)
|
|
144
146
|
conn.transaction do
|
|
145
147
|
conn[:archive_manifest].insert(
|
|
146
|
-
batch_id:
|
|
148
|
+
batch_id: batch_id,
|
|
147
149
|
source_table: table.to_s,
|
|
148
150
|
row_count: rows.size,
|
|
149
151
|
checksum: Digest::SHA256.hexdigest(compressed),
|
|
@@ -167,47 +169,47 @@ module Legion
|
|
|
167
169
|
output.string
|
|
168
170
|
end
|
|
169
171
|
|
|
170
|
-
def upload_s3(data:, table:, year:, month:,
|
|
172
|
+
def upload_s3(data:, table:, year:, month:, batch_id:)
|
|
171
173
|
raise UploadError, 'S3 backend not available: Legion::Extensions::S3::Runners::Put not defined' unless defined?(Legion::Extensions::S3::Runners::Put)
|
|
172
174
|
|
|
173
|
-
key = "legion-archive/#{table}/#{year}/#{month}/batch_#{
|
|
175
|
+
key = "legion-archive/#{table}/#{year}/#{month}/batch_#{batch_id}.jsonl.gz"
|
|
174
176
|
Legion::Extensions::S3::Runners::Put.run(key: key, body: data)
|
|
175
177
|
log.info "Archiver uploaded batch to s3 key=#{key}"
|
|
176
178
|
"s3://#{key}"
|
|
177
179
|
rescue UploadError => e
|
|
178
|
-
handle_exception(e, level: :error, handled: false, operation: :upload_s3, table: table, year: year, month: month,
|
|
180
|
+
handle_exception(e, level: :error, handled: false, operation: :upload_s3, table: table, year: year, month: month, batch_id: batch_id)
|
|
179
181
|
raise
|
|
180
182
|
rescue StandardError => e
|
|
181
|
-
handle_exception(e, level: :error, handled: true, operation: :upload_s3, table: table, year: year, month: month,
|
|
183
|
+
handle_exception(e, level: :error, handled: true, operation: :upload_s3, table: table, year: year, month: month, batch_id: batch_id)
|
|
182
184
|
raise UploadError, "S3 upload failed: #{e.message}"
|
|
183
185
|
end
|
|
184
186
|
|
|
185
|
-
def upload_azure(data:, table:, year:, month:,
|
|
187
|
+
def upload_azure(data:, table:, year:, month:, batch_id:)
|
|
186
188
|
unless defined?(Legion::Extensions::AzureStorage::Runners::Upload)
|
|
187
189
|
raise UploadError, 'Azure backend not available: Legion::Extensions::AzureStorage::Runners::Upload not defined'
|
|
188
190
|
end
|
|
189
191
|
|
|
190
|
-
blob_name = "legion-archive/#{table}/#{year}/#{month}/batch_#{
|
|
192
|
+
blob_name = "legion-archive/#{table}/#{year}/#{month}/batch_#{batch_id}.jsonl.gz"
|
|
191
193
|
Legion::Extensions::AzureStorage::Runners::Upload.run(blob_name: blob_name, data: data)
|
|
192
194
|
log.info "Archiver uploaded batch to azure blob=#{blob_name}"
|
|
193
195
|
"azure://#{blob_name}"
|
|
194
196
|
rescue UploadError => e
|
|
195
|
-
handle_exception(e, level: :error, handled: false, operation: :upload_azure, table: table, year: year, month: month,
|
|
197
|
+
handle_exception(e, level: :error, handled: false, operation: :upload_azure, table: table, year: year, month: month, batch_id: batch_id)
|
|
196
198
|
raise
|
|
197
199
|
rescue StandardError => e
|
|
198
|
-
handle_exception(e, level: :error, handled: false, operation: :upload_azure, table: table, year: year, month: month,
|
|
200
|
+
handle_exception(e, level: :error, handled: false, operation: :upload_azure, table: table, year: year, month: month, batch_id: batch_id)
|
|
199
201
|
raise UploadError, "Azure upload failed: #{e.message}"
|
|
200
202
|
end
|
|
201
203
|
|
|
202
|
-
def upload_tmpdir(data:, table:, year:, month:,
|
|
204
|
+
def upload_tmpdir(data:, table:, year:, month:, batch_id:)
|
|
203
205
|
dir = File.join(Dir.tmpdir, 'legion-archive', table.to_s, year.to_s, month.to_s)
|
|
204
206
|
FileUtils.mkdir_p(dir)
|
|
205
|
-
path = File.join(dir, "batch_#{
|
|
207
|
+
path = File.join(dir, "batch_#{batch_id}.jsonl.gz")
|
|
206
208
|
File.binwrite(path, data)
|
|
207
209
|
log.info "Archiver stored batch locally path=#{path}"
|
|
208
210
|
"file://#{path}"
|
|
209
211
|
rescue StandardError => e
|
|
210
|
-
handle_exception(e, level: :error, handled: true, operation: :upload_tmpdir, table: table, year: year, month: month,
|
|
212
|
+
handle_exception(e, level: :error, handled: true, operation: :upload_tmpdir, table: table, year: year, month: month, batch_id: batch_id)
|
|
211
213
|
raise UploadError, "Tmpdir upload failed: #{e.message}"
|
|
212
214
|
end
|
|
213
215
|
end
|
|
@@ -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
|
|