orfeas_lyra 0.9.0 → 0.9.1

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: 8e940368c4357d27be315d08d22f791d399356c36884472120c6c2a4f82731fe
4
- data.tar.gz: f59258ee1575e968c436cd1f4ab0cd8ff567438d5571f8f1fd438ccfb944446c
3
+ metadata.gz: 1ad6e90ac8c41a250e5657e98d515ae6dc1c67ac143ac0e09b5aa03da8d519e5
4
+ data.tar.gz: 3f901f07baca22e6e7a20d670d2511e6ed29ee0897d9ace09046a744178c4398
5
5
  SHA512:
6
- metadata.gz: 5e6b4a7a5ce88cbc5c10434ef0b6af0a4f09c6109f66af253fdab4a6771b9e23f3965a59bd0f5e56932358eda75c590d6d8d0b415be961e4c9b3cf42a908ca70
7
- data.tar.gz: 15d85548a5cdbc546fa86a2fe263e4be6d137e6e0597f17f13e5e75ceba2763b27c12315b3643e43f7712f134851d006ea20263eb8fcb8d6f03c26f6e7ade141
6
+ metadata.gz: 83812774c35b098d728b340806597d9e7949ae813f3005ab76f6d032628acef6bc38aad567f4c15a50f90f315e0c34a681dedf11b327ed2a60e53f25e7f1fb71
7
+ data.tar.gz: f73b3d73337a165cb8160854468ba0d8fc9bfb86678eafa9f463a517cffbdd85a2effce8460bcc2168d85cbea9fabb748ea16a6b5c9f17d7380ba832fe4c5f5c
data/CHANGELOG.md CHANGED
@@ -7,10 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.9.0] - 2026-10-14
10
+ ## [0.9.1] - 2026-11-02
11
+
12
+ Lyra's changes since 0.9.0; PAM DSL and PetriFlow change only in their gem descriptions.
13
+
14
+ ### Released
15
+ - The gems on RubyGems (`orfeas_lyra`, `orfeas_petri_flow`, `orfeas_pam_dsl`), built from tag
16
+ `v0.9.1`
17
+
18
+ ### Added
19
+ - **Writes are grouped into causal chains by default** (`config.correlate_writes`, on). Before,
20
+ Lyra carried correlation and causation only where the application called
21
+ `Lyra::Correlation.with_id`/`Lyra::Causation.with_id`, which no example did: every event was its
22
+ own chain. Now:
23
+ - every event-producing write runs in a write scope (`around_save`/`around_destroy`, first in
24
+ the chain) that fixes its event id before its callbacks run, so a write made inside them
25
+ records that event as its `causation_id`; a record saving itself inside its own callbacks is
26
+ caused by the outer save;
27
+ - the outermost write opens the correlation when none is in scope, its own event id, as the
28
+ event store does for a root event;
29
+ - `Lyra::Correlation::Middleware`, installed by the engine, gives the writes of one HTTP request
30
+ one correlation, the request id; a correlation already in scope is kept;
31
+ - `Lyra::Correlation::Job`, included into `ActiveJob::Base` by the engine, gives the writes of
32
+ one job the correlation in scope where the job was created, or the job id without one, and a
33
+ write in scope there as their cause. Both are taken when the job is created, so a job deferred
34
+ to after the commit (`enqueue_after_transaction_commit`) keeps them, and travel in its payload
35
+ (`lyra_correlation_id`, `lyra_causation_id`), so a retry does too. A job created inside a
36
+ transaction and sent at once (the Rails default) could outlive a rollback of its cause; its
37
+ cause is kept when it runs only if that event is in the log, so no job names a cause the log
38
+ lacks (one that runs before the enqueuing transaction commits loses the link).
39
+ Monitor, Hijack and event-sourcing events alike (`DomainEvents.build` takes the pre-assigned
40
+ `event_id:`, given once per write). The Olist replay runs each step as one request would
41
+ (`test/write_correlation_test.rb`).
42
+
43
+ ### Changed
44
+ - **ORFEAS is expanded as the words its letters stand for**: Object-Relational Framework for
45
+ Event-Aware Systems (README, `docs/ARCHITECTURE.md` and its citation, gem descriptions). It
46
+ read "Object-Relational to Event-Sourcing Architecture", which has no F.
47
+
48
+ ### Fixed
49
+ - **Writes that skip callbacks name who made them.** Events of `update_columns`, `touch`,
50
+ `delete`, `update_all`, `delete_all`, `insert_all`, `upsert_all` and `dependent: :nullify`, and
51
+ `has_and_belongs_to_many` links, carried correlation and causation but neither `user_id` nor
52
+ `request_id`, so the same request's writes named a user only if they ran callbacks. They now
53
+ take both from `Current`, as the documentation already said. Events written before this keep no
54
+ actor; their correlation still ties them to the request's other writes.
55
+ - **`AsyncProjectionJob` warns again when its event is not in the log.** The warning sat in a
56
+ rescue of `RubyEventStore::EventNotFound`, which `read.event` never raises (it returns nil;
57
+ `read.event!` raises), so it was never logged. The job still did nothing for a missing event.
58
+
59
+ ## [0.9.0] - 2026-10-25
11
60
 
12
61
  Everything since 0.8.0, the version the PAM article describes.
13
62
 
63
+ ### Released
64
+ - The gems on RubyGems (`orfeas_lyra`, `orfeas_petri_flow`, `orfeas_pam_dsl`), built from tag
65
+ `v0.9.0` (2026-10-19); the entries marked *after the tag* are not in them
66
+ - The replication package: https://doi.org/10.5281/zenodo.17770613
67
+ - The benchmark results: https://doi.org/10.5281/zenodo.23640846
68
+
14
69
  ### Added
15
70
  - **`config.models` takes namespace patterns, `:all` and a denylist** (`Lyra::ModelSelection`):
16
71
  `config.models = %w[Spree::* Order]`, `config.models = :all`,
@@ -344,6 +399,14 @@ Everything since 0.8.0, the version the PAM article describes.
344
399
  YAML serializer) was not affected. Found by replaying the BPI Challenge 2017 log
345
400
  (`examples/bpi2017_loan_app`), whose timestamps carry milliseconds.
346
401
  ### Changed
402
+ - **Documentation** (*after the tag*) — `docs/PERFORMANCE.md` gives the 0.9.0 measurement (three runs, with
403
+ controls for PaperTrail and for the lock on the aggregate), the SQL statements per write under the
404
+ lock, and the published runs (https://doi.org/10.5281/zenodo.23640846); the install instructions
405
+ take the gems from RubyGems (`"~> 0.9"`); the case studies, benchmark harness and formal-model
406
+ scripts are referred to as the replication package (https://doi.org/10.5281/zenodo.17770613).
407
+ Comments no longer cite issue numbers; `lib/` changes in comments only.
408
+ - **The Rakefile's testbed tasks** (*after the tag*) say where the testbed is when it is absent, and `stats:all`
409
+ skips it.
347
410
  - **ES-NoProj reads**: a stale cache entry is brought up to date from the events after its stamp
348
411
  instead of a full replay; cache entries have a process-local copy in front of the shared cache;
349
412
  an association's records are read when first needed, and a reference-link lookup intersects
@@ -454,7 +517,10 @@ ignored like any unknown option.
454
517
  write path always uses `Lyra::CommandHandler`.
455
518
 
456
519
  ### Fixed
457
- - **A thread that wrote through Lyra kept a database connection until it ended (mpantel/lyra#37).** Lyra
520
+ - **`trace_conformance_test.rb` on a fresh database** (*after the tag*) — the recorder took the commit of Lyra's own
521
+ checkpoint table, created on a connection of its own, for part of the write; it now records only
522
+ the write's connection. Test only.
523
+ - **A thread that wrote through Lyra kept a database connection until it ended.** Lyra
458
524
  called `Model.connection` (Rails' `lease_connection`) in its write, lock and read paths: the
459
525
  aggregate lock, the id generator, Genesis, ES-Async's job, ES-NoProj's cache and reference links,
460
526
  ES-Lazy's catch-up, and its maintenance calls (DualView, Rebuild, mode checks, erasure). Since
@@ -470,7 +536,7 @@ ignored like any unknown option.
470
536
  returns whatever its work leased. `test/connection_release_test.rb`: in every mode, a thread that
471
537
  writes and reads, bare or in `with_connection`, ends without a connection, and `lib/` has no
472
538
  `Model.connection` call left.
473
- - **Genesis and the reference-link catch-up assumed a second connection (#37).** Inside an open
539
+ - **Genesis and the reference-link catch-up assumed a second connection.** Inside an open
474
540
  transaction both ran their work on a thread that checked out a connection of its own and waited
475
541
  for it. Under Rails' transactional tests the pool's connection is pinned, every checkout returns
476
542
  the caller's own, locked connection, and the wait never ended: the Aegean testbed suite in
@@ -491,7 +557,7 @@ ignored like any unknown option.
491
557
  association's links with the join table, `Rebuild` restores join tables from the events, and
492
558
  Genesis records the links pre-existing rows had. Under ES-NoProj, HABTM writes failed before
493
559
  reaching Lyra (`CachedRelation` lacked `where_values_hash`); they now work and read back
494
- (`test/habtm_links_test.rb`, every mode; mpantel/lyra#21).
560
+ (`test/habtm_links_test.rb`, every mode).
495
561
  - **Optimistic locking: a stale update silently won under event sourcing.** Active Record checks
496
562
  `lock_version` in the UPDATE or DELETE that writes the row; ES-Sync, ES-Async, ES-NoProj and
497
563
  ES-Lazy skip that statement, so a stale `update` or `destroy` overwrote the newer write (a lost
@@ -503,7 +569,7 @@ ignored like any unknown option.
503
569
  model in every event-sourcing mode), and raise `ActiveRecord::StaleObjectError` as Active Record
504
570
  does (`Lyra::OptimisticLocking`). A record that expects a newer version than the log's passes,
505
571
  so streams written before the change keep working (`test/optimistic_locking_test.rb`, every
506
- mode, concurrent writers included; mpantel/lyra#20).
572
+ mode, concurrent writers included).
507
573
  - **Encrypted attributes (Active Record `encrypts`) reached the event log in clear** while their
508
574
  row held ciphertext: events are built from attributes Active Record has already decrypted.
509
575
  `Lyra.append_events`, through which every event passes, now stores those attributes' values (in
@@ -511,7 +577,7 @@ ignored like any unknown option.
511
577
  (`Lyra::EncryptedAttributes`); projections, rebuilds and DualView decrypt them, and records
512
578
  instantiated from events are decrypted by Active Record. Events written before keep their clear
513
579
  values and still read; rows written from them are encrypted. No mode leaks the value, no value is
514
- encrypted twice (`test/encrypted_attributes_test.rb`; mpantel/lyra#19).
580
+ encrypted twice (`test/encrypted_attributes_test.rb`).
515
581
  - **ES-Lazy stopped projecting after the event store was emptied or restored without its
516
582
  checkpoint table**: the checkpoint then lay past the newest event, and every new event looked
517
583
  applied. A checkpoint past the newest event is now recognised, warned about and reset (applying
@@ -589,8 +655,8 @@ ignored like any unknown option.
589
655
  are now the same whether one mode or all are generated (`monitor_mode_workflow.rb` defines
590
656
  `MonitorModeWorkflow`, `es_sync_mode_workflow.rb` `EsSyncModeWorkflow`;
591
657
  `WorkflowGenerator.workflow_file_basename(mode)`, `.workflow_class_name(mode)`).
592
- - **`already initialized constant Lyra::VERSION` in the monorepo** — Bundler's default gemspec
593
- search also loaded the nested `lyra-engine/` checkout's `lyra.gemspec`. The root `Gemfile`
658
+ - **`already initialized constant Lyra::VERSION` in a checkout holding a nested copy of the
659
+ engine** — Bundler's default gemspec search also loaded the nested copy's `lyra.gemspec`. The root `Gemfile`
594
660
  uses `gemspec glob: "lyra.gemspec"`, and the example apps' Gemfiles `glob: "lyra.gemspec"`.
595
661
  - **Hijack and event-sourcing events lost who wrote them** — the command handler wrote only
596
662
  the causal chain, so `user_id`, `request_id`, the user action and `config.metadata_proc`
@@ -651,11 +717,11 @@ ignored like any unknown option.
651
717
  (`CachedRelationDelivery`), as are the class-level `find`, `find_by` and `find_by!`.
652
718
  - **The all-modes runner skipped ES-Lazy** — `lyra:test:all_modes` and the PAM-less run now cover
653
719
  all seven configurations.
654
- - **Lyra's rake tasks ran twice, and host apps got the monorepo's maintenance tasks** — Rails
720
+ - **Lyra's rake tasks ran twice, and host apps got the development checkout's maintenance tasks** — Rails
655
721
  loads every `lib/tasks/*.rake` under an engine's root, and the engine also loaded its three
656
722
  task files explicitly, so each `lyra:mode:*`, `lyra:projections:*` and `lyra:schema:*` task
657
723
  ran twice (`lyra:mode:check` ran its full check twice), and `gems:*`, `public:*`, `stats:*` and
658
- the other monorepo tasks appeared in every host app. The engine now loads `lyra_*.rake` only,
724
+ the other maintenance tasks appeared in every host app. The engine now loads `lyra_*.rake` only,
659
725
  once.
660
726
  - **`lyra:mode:check` could certify a switch having checked nothing** — monitored models register
661
727
  as their classes load, which a rake task in development does not do, so the check saw no
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **CRUD to Event Sourcing Transformation Engine**
4
4
 
5
- *Part of the ORFEAS (Object-Relational to Event-Sourcing Architecture) Framework*
5
+ *Part of ORFEAS (Object-Relational Framework for Event-Aware Systems)*
6
6
 
7
7
  Lyra is a Rails engine that records the writes an ActiveRecord application
8
8
  makes as events (RailsEventStore), and can then move the application, one
@@ -56,9 +56,9 @@ The gem names differ from the files to require:
56
56
 
57
57
  ```ruby
58
58
  # Gemfile
59
- gem "orfeas_lyra", path: "path/to/lyra", require: "lyra"
60
- gem "orfeas_pam_dsl", path: "path/to/lyra/gems/pam_dsl", require: "pam_dsl" # optional, privacy features
61
- gem "orfeas_petri_flow", path: "path/to/lyra/gems/petri_flow", require: "petri_flow" # optional, verification
59
+ gem "orfeas_lyra", "~> 0.9", require: "lyra"
60
+ gem "orfeas_pam_dsl", "~> 0.9", require: "pam_dsl" # optional, privacy features
61
+ gem "orfeas_petri_flow", "~> 0.9", require: "petri_flow" # optional, verification
62
62
  ```
63
63
 
64
64
  Create the event store tables with the RailsEventStore 3 generator
@@ -139,7 +139,7 @@ individual calls.
139
139
  - **[Architecture Overview](docs/ARCHITECTURE.md)** - System design and components
140
140
  - **[Switching Modes](docs/MIGRATION_GUIDE.md#switching-modes)** - Who holds the mode, and how to switch safely (the deploy-time rule)
141
141
  - **[Adopting Lyra](docs/MIGRATION_GUIDE.md#adopting-lyra-in-an-existing-application)** - What "non-intrusive" means, the costs, and a checklist for a new codebase
142
- - **[Performance](docs/PERFORMANCE.md)** - What each mode costs and why (counted in SQL statements per write; throughput being re-measured)
142
+ - **[Performance](docs/PERFORMANCE.md)** - What each mode costs and why (measured on Lyra 0.9.0, with the SQL statements per write)
143
143
  - **[Troubleshooting](docs/TROUBLESHOOTING.md)** - Common problems
144
144
  - **[Workflow Generator](docs/WORKFLOW_GENERATOR.md)** - The Petri net workflow models of Lyra's modes and their verification
145
145
  - **[Changelog](CHANGELOG.md)**
@@ -189,6 +189,18 @@ DOI: [10.1145/3575879.3576006](https://doi.org/10.1145/3575879.3576006)
189
189
  EGOVIS 2022, Lecture Notes in Computer Science 13429, Springer, pp. 18–31.
190
190
  DOI: [10.1007/978-3-031-12673-4_2](https://doi.org/10.1007/978-3-031-12673-4_2)
191
191
 
192
+ ### Code and data behind the papers
193
+
194
+ Each paper's code is a tag or commit of this repository; its data and the rest of its
195
+ replication material are on Zenodo. The case studies (the Aegean ePay testbed, Solidus
196
+ with the Olist orders, the BPI 2017 loan application) are in the replication packages.
197
+
198
+ | Paper | Code | Data and replication material |
199
+ |:------|:-----|:------------------------------|
200
+ | *From ORM to OEM* (IEEE Software, 2026) | tag [`paperoem_final`](https://github.com/mpantel/lyra-engine/tree/paperoem_final), commit `b863d2e` (numbered 0.6.0, three commits after the [`v0.6.0`](https://github.com/mpantel/lyra-engine/tree/v0.6.0) release) | The benchmark run, its SQL statement trace, the per-trial values and 95% confidence intervals, and the harness as measured: [10.5281/zenodo.23648730](https://doi.org/10.5281/zenodo.23648730) |
201
+ | *PAM* (Requirements Engineering, 2026) | tag [`v0.8.0`](https://github.com/mpantel/lyra-engine/tree/v0.8.0); `orfeas_pam_dsl` 0.8.0 on RubyGems | The policies, the testbed's models, tests and reports, the Solidus replication and the microbenchmark: [10.5281/zenodo.23649008](https://doi.org/10.5281/zenodo.23649008) |
202
+ | *ORFEAS* (IEEE Transactions on Software Engineering, under review) | tag [`v0.9.0`](https://github.com/mpantel/lyra-engine/tree/v0.9.0); `orfeas_lyra` 0.9.0 on RubyGems | Replication package: [10.5281/zenodo.23646667](https://doi.org/10.5281/zenodo.23646667) (all versions: [10.5281/zenodo.17770613](https://doi.org/10.5281/zenodo.17770613)); benchmark results (CC BY-NC-SA 4.0): [10.5281/zenodo.23640846](https://doi.org/10.5281/zenodo.23640846) |
203
+
192
204
  ### Research Areas
193
205
 
194
206
  - **Event Sourcing Patterns**: Formal models for CRUD-to-event transformation
@@ -269,7 +281,8 @@ If you use this software in academic research, please cite:
269
281
  author={Pantelelis, Michail},
270
282
  year={2026},
271
283
  note={Part of ORFEAS Framework},
272
- url={https://github.com/mpantel/lyra-engine}
284
+ url={https://github.com/mpantel/lyra-engine},
285
+ doi={10.5281/zenodo.17770613}
273
286
  }
274
287
  ```
275
288
 
data/Rakefile CHANGED
@@ -57,6 +57,9 @@ task default: :test
57
57
  # Shared constants
58
58
  # =============================================================================
59
59
  TESTBED_DIR = File.expand_path("examples/aegean_epay_testbed", __dir__)
60
+ # The testbed is in the replication package, not in the lyra-engine repository.
61
+ TESTBED_MISSING = "The Aegean testbed is not in this checkout; it is in the replication package " \
62
+ "(https://doi.org/10.5281/zenodo.17770613)."
60
63
 
61
64
  # =============================================================================
62
65
  # Benchmark tasks
@@ -69,6 +72,8 @@ namespace :benchmark do
69
72
  # Usage: rake benchmark:modes -- --scales=1000,5000 --modes=disabled,monitor
70
73
  args = ARGV.drop_while { |a| a != "--" }.drop(1).join(" ")
71
74
 
75
+ abort TESTBED_MISSING unless File.directory?(TESTBED_DIR)
76
+
72
77
  Dir.chdir(TESTBED_DIR) do
73
78
  # Use bundle exec with testbed's Gemfile to ensure correct dependencies
74
79
  cmd = "bundle exec ruby perf/run_mode_comparison_benchmark.rb #{args}"
@@ -300,8 +305,10 @@ namespace :stats do
300
305
  results[:petri_flow] = analyze_directory("gems/petri_flow/lib", "gems/petri_flow/test", "PetriFlow")
301
306
 
302
307
  # Aegean E-Pay Testbed
303
- puts "Analyzing Aegean E-Pay Testbed..."
304
- results[:testbed] = analyze_rails_app("examples/aegean_epay_testbed", "Aegean E-Pay Testbed")
308
+ if File.directory?(TESTBED_DIR)
309
+ puts "Analyzing Aegean E-Pay Testbed..."
310
+ results[:testbed] = analyze_rails_app("examples/aegean_epay_testbed", "Aegean E-Pay Testbed")
311
+ end
305
312
 
306
313
  puts ""
307
314
 
@@ -337,6 +344,7 @@ namespace :stats do
337
344
 
338
345
  desc "Generate statistics for Aegean E-Pay Testbed"
339
346
  task :testbed do
347
+ abort TESTBED_MISSING unless File.directory?(TESTBED_DIR)
340
348
  result = analyze_rails_app("examples/aegean_epay_testbed", "Aegean E-Pay Testbed")
341
349
  print_component_report(result)
342
350
  end
@@ -538,7 +546,7 @@ namespace :stats do
538
546
  require "json"
539
547
  require "fileutils"
540
548
 
541
- report_dir = "examples/aegean_epay_testbed/reports"
549
+ report_dir = File.directory?(TESTBED_DIR) ? "examples/aegean_epay_testbed/reports" : "tmp/stats"
542
550
  FileUtils.mkdir_p(report_dir)
543
551
 
544
552
  timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
@@ -36,6 +36,8 @@ module Lyra
36
36
  def discrepancies
37
37
  @model_class = params[:model_class].constantize
38
38
  @discrepancies = DualView.find_discrepancies(@model_class)
39
+ # find_discrepancies returns only the records that differ
40
+ @records_checked = @model_class.count
39
41
 
40
42
  respond_to do |format|
41
43
  format.html
@@ -22,10 +22,11 @@
22
22
  .lyra-discrepancies .comparison-col h4 { margin: 0 0 10px 0; font-size: 14px; color: #495057; }
23
23
  .lyra-discrepancies .comparison-col .source-label { font-size: 12px; color: #6c757d; margin-bottom: 5px; }
24
24
  .lyra-discrepancies .field-list { list-style: none; padding: 0; margin: 0; }
25
- .lyra-discrepancies .field-list li { padding: 8px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
25
+ .lyra-discrepancies .field-list li { padding: 8px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; gap: 10px; }
26
26
  .lyra-discrepancies .field-list li:last-child { border-bottom: none; }
27
27
  .lyra-discrepancies .field-name { font-weight: 500; color: #1a1a2e; }
28
- .lyra-discrepancies .field-value { color: #495057; font-family: monospace; font-size: 13px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
28
+ .lyra-discrepancies .field-value { color: #495057; font-family: monospace; font-size: 13px; max-width: 70%; overflow-wrap: anywhere; text-align: right; }
29
+ .lyra-discrepancies .existence-note { margin: 0 0 15px 0; color: #495057; font-size: 14px; }
29
30
  .lyra-discrepancies .field-mismatch { background: #fff3cd; padding: 2px 6px; border-radius: 3px; }
30
31
  .lyra-discrepancies .empty-state { text-align: center; padding: 60px 40px; color: #6c757d; background: #d4edda; border: 1px solid #28a745; border-radius: 8px; }
31
32
  .lyra-discrepancies .empty-state h3 { color: #155724; margin-bottom: 10px; }
@@ -45,9 +46,14 @@
45
46
  Overview of data consistency between the database and event store.
46
47
  </p>
47
48
  <%
48
- total_records = @discrepancies.count
49
- mismatches = @discrepancies.count { |d| d[:status] == :mismatch || d[:differences]&.any? }
49
+ # DualView.find_discrepancies returns only the records that differ, each
50
+ # a DualView#compare result: differences maps a field to
51
+ # { crud:, event_sourced: }, or is { exists_mismatch: true } when the row
52
+ # and the log disagree on whether the record exists.
53
+ mismatches = @discrepancies.size
54
+ total_records = [@records_checked.to_i, mismatches].max
50
55
  matches = total_records - mismatches
56
+ show_value = ->(value) { value.nil? ? "(none)" : value.to_s }
51
57
  %>
52
58
  <div class="summary-cards">
53
59
  <div class="summary-card">
@@ -69,7 +75,7 @@
69
75
  Per-record comparison showing differences between database values and event-reconstructed state.
70
76
  </p>
71
77
 
72
- <% if @discrepancies.empty? || mismatches == 0 %>
78
+ <% if mismatches == 0 %>
73
79
  <div class="empty-state">
74
80
  <h3>All Records Match</h3>
75
81
  <p>No discrepancies found between the database and event store.</p>
@@ -77,48 +83,71 @@
77
83
  </div>
78
84
  <% else %>
79
85
  <% @discrepancies.each do |discrepancy| %>
80
- <% has_differences = discrepancy[:status] == :mismatch || discrepancy[:differences]&.any? %>
86
+ <%
87
+ differences = discrepancy[:differences] || {}
88
+ crud_view = discrepancy[:crud_view] || {}
89
+ es_view = discrepancy[:event_sourced_view] || {}
90
+ record_id = discrepancy.dig(:metadata, :model_id)
91
+ %>
81
92
  <div class="discrepancy-card">
82
- <div class="discrepancy-header <%= has_differences ? '' : 'match' %>">
83
- <h3><%= @model_class.name %> #<%= discrepancy[:id] || discrepancy[:record_id] %></h3>
84
- <span class="status <%= has_differences ? 'mismatch' : 'match' %>">
85
- <%= has_differences ? 'Mismatch' : 'Match' %>
86
- </span>
93
+ <div class="discrepancy-header">
94
+ <h3><%= @model_class.name %> #<%= record_id %></h3>
95
+ <span class="status mismatch">Mismatch</span>
87
96
  </div>
88
- <% if has_differences %>
89
- <div class="discrepancy-body">
90
- <div class="comparison-grid">
91
- <div class="comparison-col">
92
- <p class="source-label">Database State</p>
93
- <h4>Current Values</h4>
94
- <ul class="field-list">
95
- <% (discrepancy[:differences] || discrepancy[:db_state] || {}).each do |field, values| %>
97
+ <div class="discrepancy-body">
98
+ <% if differences[:exists_mismatch] %>
99
+ <p class="existence-note">
100
+ <% if crud_view[:exists] %>
101
+ The row exists, but the event log does not describe this record
102
+ (<%= es_view[:destroyed] ? "its events end in a destroy" : "no events" %>):
103
+ it was written before Lyra was enabled, or by a write that bypassed it.
104
+ <% else %>
105
+ The event log describes this record, but its row is gone: it was
106
+ deleted by a write that bypassed Lyra.
107
+ <% end %>
108
+ </p>
109
+ <% end %>
110
+ <div class="comparison-grid">
111
+ <div class="comparison-col">
112
+ <p class="source-label">Database State</p>
113
+ <h4>Current Values</h4>
114
+ <ul class="field-list">
115
+ <% if differences[:exists_mismatch] %>
116
+ <li>
117
+ <span class="field-name">row</span>
118
+ <span class="field-value field-mismatch"><%= crud_view[:exists] ? "present" : "absent" %></span>
119
+ </li>
120
+ <% else %>
121
+ <% differences.each do |field, values| %>
96
122
  <li>
97
123
  <span class="field-name"><%= field %></span>
98
- <span class="field-value field-mismatch">
99
- <%= values.is_a?(Array) ? values[0] : values %>
100
- </span>
124
+ <span class="field-value field-mismatch"><%= show_value.(values[:crud]) %></span>
101
125
  </li>
102
126
  <% end %>
103
- </ul>
104
- </div>
105
- <div class="comparison-col">
106
- <p class="source-label">Event-Sourced State</p>
107
- <h4>Projected Values</h4>
108
- <ul class="field-list">
109
- <% (discrepancy[:differences] || discrepancy[:event_state] || {}).each do |field, values| %>
127
+ <% end %>
128
+ </ul>
129
+ </div>
130
+ <div class="comparison-col">
131
+ <p class="source-label">Event-Sourced State</p>
132
+ <h4>Projected Values</h4>
133
+ <ul class="field-list">
134
+ <% if differences[:exists_mismatch] %>
135
+ <li>
136
+ <span class="field-name">events</span>
137
+ <span class="field-value field-mismatch"><%= es_view[:events_count].to_i %></span>
138
+ </li>
139
+ <% else %>
140
+ <% differences.each do |field, values| %>
110
141
  <li>
111
142
  <span class="field-name"><%= field %></span>
112
- <span class="field-value field-mismatch">
113
- <%= values.is_a?(Array) ? values[1] : values %>
114
- </span>
143
+ <span class="field-value field-mismatch"><%= show_value.(values[:event_sourced]) %></span>
115
144
  </li>
116
145
  <% end %>
117
- </ul>
118
- </div>
146
+ <% end %>
147
+ </ul>
119
148
  </div>
120
149
  </div>
121
- <% end %>
150
+ </div>
122
151
  </div>
123
152
  <% end %>
124
153
  <% end %>
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Lyra
4
4
  module Associations
5
- # Links and unlinks of has_and_belongs_to_many associations (#21).
5
+ # Links and unlinks of has_and_belongs_to_many associations.
6
6
  #
7
7
  # A HABTM join table has no model of its own: Active Record writes it
8
8
  # through an anonymous class (Item::HABTM_Tags), which no interceptor saw,
@@ -173,7 +173,7 @@ module Lyra
173
173
  rows: rows,
174
174
  timestamp: Time.current
175
175
  }
176
- metadata = { correlation_id: Lyra::Correlation.current_id, causation_id: Lyra::Causation.current_id }.compact
176
+ metadata = Lyra::BypassEvents.current_metadata
177
177
  metadata[:genesis] = true if genesis
178
178
  event_class = operation == LINKED ? Lyra::Events::AssociationLinked : Lyra::Events::AssociationUnlinked
179
179
  event_class.new(data: data, metadata: Lyra::Privacy.stamp(owner, data, metadata))
@@ -55,11 +55,7 @@ module Lyra
55
55
 
56
56
  def publish(model_class, id, operation, attributes:, changes: {}, source:)
57
57
  event_class = event_class_for(model_class, operation)
58
- metadata = {
59
- correlation_id: Lyra::Correlation.current_id,
60
- causation_id: Lyra::Causation.current_id,
61
- source_key(source) => source
62
- }.compact
58
+ metadata = current_metadata.merge(source_key(source) => source)
63
59
 
64
60
  data = {
65
61
  model_class: model_class.name,
@@ -79,6 +75,19 @@ module Lyra
79
75
  end
80
76
  end
81
77
 
78
+ # Who made the change and what it belongs with, for a write no record's
79
+ # callbacks attribute. Read from the application's Current, as for a write
80
+ # through callbacks; a bulk write has no record to ask, so a model's
81
+ # lyra_current_user_id hook does not apply here.
82
+ def current_metadata
83
+ {
84
+ user_id: current(:user)&.id,
85
+ request_id: current(:request_id),
86
+ correlation_id: Lyra::Correlation.current_id,
87
+ causation_id: Lyra::Causation.current_id
88
+ }.compact
89
+ end
90
+
82
91
  # Attributes of every row the relation matches, keyed by primary key.
83
92
  # Taken before a bulk write so the events can carry old values.
84
93
  def snapshot(model_class, relation)
@@ -162,6 +171,10 @@ module Lyra
162
171
 
163
172
  private
164
173
 
174
+ def current(attribute)
175
+ defined?(::Current) && ::Current.respond_to?(attribute) ? ::Current.public_send(attribute) : nil
176
+ end
177
+
165
178
  # Column => [old, new] for every non-timestamp column that changed.
166
179
  def diff(old_attrs, new_attrs)
167
180
  new_attrs.each_with_object({}) do |(column, new_value), acc|
@@ -139,9 +139,13 @@ module Lyra
139
139
  # domain events (Lyra::DomainEvents).
140
140
  def create_events(operation, id, data)
141
141
  own = create_event(operation, id, data)
142
+ record = command.record
142
143
  Lyra::DomainEvents.build(
143
144
  command.model_class, operation, data: own.data, metadata: own.metadata.to_h,
144
- default_class: own.class, record: command.record, changes: changes_for(operation, data)
145
+ default_class: own.class, record: record, changes: changes_for(operation, data),
146
+ # The id the record's write scope fixed, so the writes its callbacks
147
+ # made can name this event as their cause.
148
+ event_id: record.respond_to?(:lyra_take_own_event_id) ? record.lyra_take_own_event_id : nil
145
149
  )
146
150
  end
147
151
 
@@ -33,6 +33,12 @@ module Lyra
33
33
  # (default) follows mode_transition_gate; true; false. Checked at most
34
34
  # every mode_sync_interval seconds.
35
35
  attr_accessor :mode_sync, :mode_sync_interval
36
+ # Group writes into causal chains (Lyra::Correlation): the writes of one
37
+ # HTTP request share a correlation (the request id); a write made inside
38
+ # another write's callbacks records that write's event as its cause; the
39
+ # outermost write opens the correlation when none is in scope. On by
40
+ # default; false leaves both to the application's own with_id calls.
41
+ attr_accessor :correlate_writes
36
42
  # Stamp each event's metadata with the privacy annotation of the personal
37
43
  # data it carries (Lyra::Privacy.stamp). Off by default: it adds work to
38
44
  # every write, measured separately.
@@ -110,6 +116,7 @@ module Lyra
110
116
  @dual_view_discrepancy_handler = nil
111
117
  @mode_sync = nil
112
118
  @mode_sync_interval = 5
119
+ @correlate_writes = true
113
120
  @annotate_privacy = false
114
121
  @record_access_events = false
115
122
  @access_metadata_proc = nil
@@ -24,6 +24,95 @@ module Lyra
24
24
  Thread.current[:lyra_correlation_id] = previous_id
25
25
  end
26
26
  end
27
+
28
+ # The writes of one HTTP request share a correlation: the request id
29
+ # (ActionDispatch::RequestId), or a new id without one. A correlation
30
+ # already in scope is kept. Installed by the engine; off with
31
+ # config.correlate_writes = false.
32
+ class Middleware
33
+ def initialize(app)
34
+ @app = app
35
+ end
36
+
37
+ def call(env)
38
+ return @app.call(env) unless Lyra.config.correlate_writes && Correlation.current_id.nil?
39
+
40
+ Correlation.with_id(env["action_dispatch.request_id"].presence) { @app.call(env) }
41
+ end
42
+ end
43
+
44
+ # The writes of one background job share a correlation: the one in scope
45
+ # where the job was created (perform_later in a request, or in another
46
+ # write's callbacks), or the job id without one. A write in scope there is
47
+ # the cause of the job's outermost writes. Both are taken when the job is
48
+ # created, not when it is serialized: a job deferred to after the commit
49
+ # (enqueue_after_transaction_commit) is serialized outside the write that
50
+ # enqueued it. They travel in the job's payload, so a retry keeps them.
51
+ # Included into ActiveJob::Base by the engine; off with
52
+ # config.correlate_writes = false.
53
+ #
54
+ # A job created inside a transaction and sent at once (not deferred: the
55
+ # Rails default) can outlive a rollback of the write that caused it. Its
56
+ # cause is unconfirmed, and kept when the job runs only if that event is
57
+ # in the log, so no job names a cause the log lacks. A job that runs
58
+ # before the enqueuing transaction commits loses the link, not its
59
+ # correlation.
60
+ module Job
61
+ extend ActiveSupport::Concern
62
+
63
+ included do
64
+ around_perform :lyra_with_enqueuing_scope
65
+ end
66
+
67
+ attr_reader :lyra_correlation_id, :lyra_causation_id
68
+
69
+ def initialize(...)
70
+ super
71
+ return unless Lyra.config.correlate_writes
72
+
73
+ @lyra_correlation_id = Correlation.current_id
74
+ @lyra_causation_id = Causation.current_id
75
+ @lyra_cause_unconfirmed = !!@lyra_causation_id && !self.class.enqueue_after_transaction_commit &&
76
+ ActiveRecord::Base.with_connection(&:transaction_open?)
77
+ end
78
+
79
+ def serialize
80
+ data = super
81
+ data["lyra_correlation_id"] = lyra_correlation_id if lyra_correlation_id
82
+ data["lyra_causation_id"] = lyra_causation_id if lyra_causation_id
83
+ data["lyra_cause_unconfirmed"] = true if @lyra_cause_unconfirmed
84
+ data
85
+ end
86
+
87
+ def deserialize(job_data)
88
+ super
89
+ @lyra_correlation_id = job_data["lyra_correlation_id"]
90
+ @lyra_causation_id = job_data["lyra_causation_id"]
91
+ @lyra_cause_unconfirmed = job_data["lyra_cause_unconfirmed"] == true
92
+ end
93
+
94
+ private
95
+
96
+ # A correlation already in scope (a job performed inline, inside the
97
+ # request that created it) is kept, as by the Middleware.
98
+ def lyra_with_enqueuing_scope
99
+ return yield unless Lyra.config.correlate_writes && Correlation.current_id.nil?
100
+
101
+ Correlation.with_id(lyra_correlation_id || job_id) do
102
+ Causation.with_id(lyra_confirmed_cause) { yield }
103
+ end
104
+ end
105
+
106
+ # The cause, once it is in the log; nil if it is not. Decided on the
107
+ # first attempt: a retry carries the outcome in its payload.
108
+ def lyra_confirmed_cause
109
+ return lyra_causation_id unless @lyra_cause_unconfirmed
110
+
111
+ @lyra_cause_unconfirmed = false
112
+ @lyra_causation_id = nil unless Lyra.config.event_store.read.event(lyra_causation_id)
113
+ lyra_causation_id
114
+ end
115
+ end
27
116
  end
28
117
 
29
118
  # Causation system for tracking event chains
@@ -89,16 +89,20 @@ module Lyra
89
89
  #
90
90
  # @param default_class [Class] the CRUD event class for the operation
91
91
  # @param data [Hash] the event envelope; metadata [Hash] its metadata
92
- def build(model_class, operation, data:, metadata:, default_class:, record:, changes:)
92
+ # @param event_id [String, nil] the write's own event id when its write
93
+ # scope fixed one (CrudInterceptor#lyra_write_scope); nil lets the
94
+ # store assign one
95
+ def build(model_class, operation, data:, metadata:, default_class:, record:, changes:, event_id: nil)
96
+ id = event_id ? { event_id: event_id } : {}
93
97
  rules = rules_for(model_class)
94
- return [default_class.new(data: data, metadata: metadata)] if rules.empty? || record.nil?
98
+ return [default_class.new(**id, data: data, metadata: metadata)] if rules.empty? || record.nil?
95
99
 
96
100
  changes = normalize_changes(changes)
97
101
  matching = rules.select { |rule| rule.applies?(operation, record, changes) }
98
102
  own_rule = matching.find { |rule| !rule.also }
99
103
 
100
104
  own = event_class_for(own_rule, default_class).new(
101
- data: with_payload(data, own_rule, record, changes), metadata: metadata
105
+ **id, data: with_payload(data, own_rule, record, changes), metadata: metadata
102
106
  )
103
107
  extras = matching.select(&:also).map do |rule|
104
108
  event_class_for(rule, nil).new(
@@ -5,7 +5,7 @@ module Lyra
5
5
  #
6
6
  # Events are built from a model's attributes and changes, which Active
7
7
  # Record has already decrypted, so an encrypted column reached the log in
8
- # clear while its row held ciphertext (issue #19). Every event Lyra writes
8
+ # clear while its row held ciphertext. Every event Lyra writes
9
9
  # passes through Lyra.append_events, which seals it here: the values of the
10
10
  # model's encrypted attributes, in attributes and in changes ([old, new]),
11
11
  # are replaced by their ciphertext, as Active Record writes it to the row.
data/lib/lyra/engine.rb CHANGED
@@ -84,6 +84,14 @@ module Lyra
84
84
  end
85
85
  end
86
86
 
87
+ # Lyra::Correlation::Middleware and Lyra::Correlation::Job: the writes of
88
+ # one request share a correlation, and a job's writes the one in scope
89
+ # where it was created (config.correlate_writes).
90
+ initializer "lyra.request_correlation" do |app|
91
+ app.config.middleware.use Lyra::Correlation::Middleware
92
+ ActiveSupport.on_load(:active_job) { include Lyra::Correlation::Job }
93
+ end
94
+
87
95
  # config.verify_mapping!: verify at boot, once the models are
88
96
  # instrumented; a failure stops the boot.
89
97
  initializer "lyra.verify_mapping", after: "lyra.apply_declared_models" do
@@ -119,7 +127,7 @@ module Lyra
119
127
  end
120
128
 
121
129
  # Rails loads every lib/tasks/*.rake under an engine's root into the
122
- # host application. In the monorepo that root also holds its own
130
+ # host application. In a development checkout that root also holds its own
123
131
  # maintenance tasks (gems:, public:, stats:, ...), and the Lyra task
124
132
  # files loaded here explicitly as well, so each Lyra task ran twice
125
133
  # (lyra:mode:check checked everything twice). Host applications get
@@ -84,6 +84,15 @@ module Lyra
84
84
  after_update :lyra_intercept_update, if: :lyra_events_enabled?
85
85
  after_destroy :lyra_intercept_destroy, if: :lyra_events_enabled?
86
86
 
87
+ # Every event-producing write runs in a write scope
88
+ # (config.correlate_writes): its event id is fixed before any of its
89
+ # callbacks, so the writes they make record it as their cause, and the
90
+ # outermost write opens the correlation when none is in scope. First
91
+ # in each chain, so it wraps the model's own callbacks, whatever order
92
+ # the model declares them in.
93
+ around_save :lyra_write_scope, if: :lyra_events_enabled?, prepend: true
94
+ around_destroy :lyra_write_scope, if: :lyra_events_enabled?, prepend: true
95
+
87
96
  # Hijack and event-sourcing modes build their event in WriteHooks, after
88
97
  # every before_* callback and just before the row write (see below).
89
98
  # Only PaperTrail is switched off here, first, so that its own
@@ -147,7 +156,9 @@ module Lyra
147
156
  user_id: lyra_current_user_id,
148
157
  request_id: lyra_current_request_id,
149
158
  correlation_id: Lyra::Correlation.current_id,
150
- causation_id: Lyra::Causation.current_id,
159
+ # Inside its own write scope the current cause is this write itself;
160
+ # its own cause is the one in scope when the write began.
161
+ causation_id: @lyra_in_write_scope ? @lyra_write_cause_id : Lyra::Causation.current_id,
151
162
  action_id: lyra_current_action_id,
152
163
  user_action: lyra_current_user_action
153
164
  }
@@ -155,6 +166,34 @@ module Lyra
155
166
  base_metadata.merge(lyra_custom_metadata(operation))
156
167
  end
157
168
 
169
+ # The event id fixed for this write by its write scope, for the write's
170
+ # own event: given once, so no two events can share it; nil outside a
171
+ # scope or once taken (the store then assigns one).
172
+ def lyra_take_own_event_id
173
+ return nil unless @lyra_in_write_scope && !@lyra_write_event_id_taken
174
+
175
+ @lyra_write_event_id_taken = true
176
+ @lyra_write_event_id
177
+ end
178
+
179
+ # around_save/around_destroy (see the callbacks above). Nested scopes of
180
+ # the same record (a save inside its own callbacks) restore the outer
181
+ # one on the way out.
182
+ def lyra_write_scope
183
+ return yield unless Lyra.config.correlate_writes
184
+
185
+ outer = [@lyra_in_write_scope, @lyra_write_event_id, @lyra_write_cause_id, @lyra_write_event_id_taken]
186
+ @lyra_write_cause_id = Lyra::Causation.current_id
187
+ @lyra_write_event_id = SecureRandom.uuid
188
+ @lyra_write_event_id_taken = false
189
+ @lyra_in_write_scope = true
190
+ Lyra::Correlation.with_id(Lyra::Correlation.current_id || @lyra_write_event_id) do
191
+ Lyra::Causation.with_id(@lyra_write_event_id) { yield }
192
+ end
193
+ ensure
194
+ @lyra_in_write_scope, @lyra_write_event_id, @lyra_write_cause_id, @lyra_write_event_id_taken = outer if outer
195
+ end
196
+
158
197
  class_methods do
159
198
  # Enable Lyra monitoring for this model
160
199
  # The model as it was at +time+, rebuilt from its event streams
@@ -808,7 +847,8 @@ module Lyra
808
847
  events = Lyra::DomainEvents.build(
809
848
  self.class, operation, data: data, metadata: metadata,
810
849
  default_class: lyra_event_class_for(operation),
811
- record: self, changes: operation == :destroyed ? {} : previous_changes
850
+ record: self, changes: operation == :destroyed ? {} : previous_changes,
851
+ event_id: lyra_take_own_event_id
812
852
  )
813
853
 
814
854
  Lyra.append_events(events, stream_name: lyra_stream_name)
@@ -105,11 +105,12 @@ module Lyra
105
105
  model_class.with_connection { |c| Lyra::AdvisoryLock.xact_lock(c, stream, purpose: "ES-Async projection") }
106
106
  end
107
107
 
108
+ # read.event returns nil for a missing event (read.event! raises): the
109
+ # warning sat in a rescue of EventNotFound and was never logged.
108
110
  def load_event(event_id)
109
- Lyra.config.event_store.read.event(event_id)
110
- rescue RubyEventStore::EventNotFound
111
- Rails.logger.warn("Lyra::AsyncProjectionJob: Event #{event_id} not found")
112
- nil
111
+ event = Lyra.config.event_store.read.event(event_id)
112
+ Rails.logger.warn("Lyra::AsyncProjectionJob: Event #{event_id} not found") unless event
113
+ event
113
114
  end
114
115
  end
115
116
  end
@@ -117,7 +117,7 @@ module Lyra
117
117
  #
118
118
  # One thread per model and process catches up; the others wait for it
119
119
  # without taking a connection, so a burst of first lookups needs one
120
- # connection beyond the callers' own, not one each (issue #37).
120
+ # connection beyond the callers' own, not one each.
121
121
  def catch_up(model_class, force: false)
122
122
  model_class = owner(model_class)
123
123
  return 0 if !force && ready?(model_class)
data/lib/lyra/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Lyra
2
- VERSION = "0.9.0"
2
+ VERSION = "0.9.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: orfeas_lyra
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis