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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d929f0ed083e64145e6022ee40af61b677b852513e071ec9a986267837953a2
4
- data.tar.gz: ed00e7cb0c7670378212cc1f8df39c893f0efbaf7a71961188a411a1956dcab9
3
+ metadata.gz: ec41889e66777b7940c00db2ed4f19ec704145ea27b149604ba82a168fed9e0b
4
+ data.tar.gz: a1d0eec9f3cd9e84d63a4b1eb4e145330b4cdd6354d742ca6f003ea78558ca4c
5
5
  SHA512:
6
- metadata.gz: fdf3cf290669b970b523c3de8e5d6b42b9b14d877bd7260155c2067a708745cd59120605059dc1d5af85fd94f0a63d0c1e4e39ae4b7870409357572085b1d5f2
7
- data.tar.gz: 3b1c0bb52ba3bfcc0249f6a6a4c0185b213f7e6cd8201989f5cd0279be1d9e1df1e364c0479d48920397f7e693ff5a63f557048acdaaefecdd32651e212922c7
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.8.0 | **Ruby**: >= 3.4 | **License**: Apache-2.0
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 # Disconnect and close query file logger
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.drain { |entry| process(entry) }
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
@@ -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 = 0
46
- conn.transaction do
47
- conn[archive_table].where(original_id: ids).each do |row|
48
- restore_row = row.except(:id, :archived_at, :original_id, :original_created_at, :original_updated_at)
49
- restore_row[:id] = row[:original_id]
50
- restore_row[:created_at] = row[:original_created_at]
51
- restore_row[:updated_at] = row[:original_updated_at]
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[archive_table].where(original_id: ids).delete
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
- candidates = conn[:tasks]
86
- .where(status: %w[completed failed])
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
- candidates = conn[source].where { created_at < cutoff }.limit(batch_size)
141
- count = candidates.count
142
- return count if dry_run || count.zero?
143
-
144
- conn.transaction do
145
- candidates.each do |row|
146
- archive_row = row.dup
147
- archive_row[:original_id] = archive_row.delete(:id)
148
- archive_row[:original_created_at] = archive_row.delete(:created_at)
149
- archive_row[:original_updated_at] = archive_row.delete(:updated_at)
150
- archive_row[:archived_at] = Time.now
151
- conn[destination].insert(archive_row)
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: candidates.select(:id)).delete
146
+ conn[source].where(id: rows.map { |row| row[:id] }).delete
147
+ rows.size
154
148
  end
155
- count
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)
@@ -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:, batch_n:, backend:)
51
- log.info "Archiver storing batch table=#{table} backend=#{backend || :tmpdir} year=#{year} month=#{month} batch=#{batch_n}"
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, batch_n: batch_n)
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, batch_n: batch_n)
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, batch_n: batch_n)
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:, batch_n:, now:, storage_backend:)
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: compressed,
124
- table: table.to_s,
125
- year: now.year,
126
- month: now.month,
127
- batch_n: batch_n,
128
- backend: storage_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: SecureRandom.uuid,
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:, batch_n:)
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_#{batch_n}.jsonl.gz"
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, batch_n: batch_n)
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, batch_n: batch_n)
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:, batch_n:)
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_#{batch_n}.jsonl.gz"
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, batch_n: batch_n)
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, batch_n: batch_n)
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:, batch_n:)
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_#{batch_n}.jsonl.gz")
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, batch_n: batch_n)
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: records.size }
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
- conn.transaction do
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 the chain ordered by creation time and verify that
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(:created_at, :id)
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(:created_at, :id).limit(limit).all.map { |r| deserialize(r) }
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
- last = conn[:audit_records]
140
- .select(:chain_hash)
141
- .where(chain_id: chain_id)
142
- .order(Sequel.desc(:created_at), Sequel.desc(:id))
143
- .first
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