orfeas_lyra 0.9.0 → 0.9.2

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: 4a75016e10096d61497bb69e58b2570d23ca9a99a8206377fe94216d1db26eb9
4
+ data.tar.gz: a139ef32e0dd3e83ba2c5ce1f182e6cb83360f2208faea20a1330536ee63edae
5
5
  SHA512:
6
- metadata.gz: 5e6b4a7a5ce88cbc5c10434ef0b6af0a4f09c6109f66af253fdab4a6771b9e23f3965a59bd0f5e56932358eda75c590d6d8d0b415be961e4c9b3cf42a908ca70
7
- data.tar.gz: 15d85548a5cdbc546fa86a2fe263e4be6d137e6e0597f17f13e5e75ceba2763b27c12315b3643e43f7712f134851d006ea20263eb8fcb8d6f03c26f6e7ade141
6
+ metadata.gz: 9d8e2c114fcae82c8dd8735758da868eb4e2aef90f4c7ad53f1385b6f39d8d182f57e9026d8e90d4a283501ae6a7fcdb97889d989183deee427252794fb1d8b4
7
+ data.tar.gz: 6817174882333de4336a7907cae0a47c1690712f85ec1f1cb96cd676e4b6ada397cf17c64a1249a5a96623cc5a33ee60c728f5c92d1ffc80bcede9ac4aa76e42
data/CHANGELOG.md CHANGED
@@ -7,10 +7,81 @@ 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.2] - 2026-11-03
11
+
12
+ One fix in Lyra; PAM DSL and PetriFlow are unchanged (0.9.1).
13
+
14
+ ### Fixed
15
+ - **ES-Async read-your-writes no longer races a projection job.** With read-your-writes
16
+ (`each_write: true`), the writer projected its own event without the per-stream lock that
17
+ `AsyncProjectionJob` holds. A job that had read the record's stream before that event
18
+ committed could write the row afterwards, putting it back to the older value until the newer
19
+ event's job ran: a reader in between saw the record step back (an application's suite, run
20
+ with real background projection, read a refund as "approved" just after it was "processed").
21
+ `Lyra::Consistency::ReadYourWrites` now takes the job's lock before projecting, held to the
22
+ end of the writer's transaction. Final states converged before as well. Plain ES-Async (no
23
+ read-your-writes) was not affected. `test/concurrent_same_record_test.rb` reproduces the
24
+ interleaving.
25
+
26
+ ## [0.9.1] - 2026-11-02
27
+
28
+ Lyra's changes since 0.9.0; PAM DSL and PetriFlow change only in their gem descriptions.
29
+
30
+ ### Released
31
+ - The gems on RubyGems (`orfeas_lyra`, `orfeas_petri_flow`, `orfeas_pam_dsl`), built from tag
32
+ `v0.9.1` and pushed on 2026-11-02
33
+
34
+ ### Added
35
+ - **Writes are grouped into causal chains by default** (`config.correlate_writes`, on). Before,
36
+ Lyra carried correlation and causation only where the application called
37
+ `Lyra::Correlation.with_id`/`Lyra::Causation.with_id`, which no example did: every event was its
38
+ own chain. Now:
39
+ - every event-producing write runs in a write scope (`around_save`/`around_destroy`, first in
40
+ the chain) that fixes its event id before its callbacks run, so a write made inside them
41
+ records that event as its `causation_id`; a record saving itself inside its own callbacks is
42
+ caused by the outer save;
43
+ - the outermost write opens the correlation when none is in scope, its own event id, as the
44
+ event store does for a root event;
45
+ - `Lyra::Correlation::Middleware`, installed by the engine, gives the writes of one HTTP request
46
+ one correlation, the request id; a correlation already in scope is kept;
47
+ - `Lyra::Correlation::Job`, included into `ActiveJob::Base` by the engine, gives the writes of
48
+ one job the correlation in scope where the job was created, or the job id without one, and a
49
+ write in scope there as their cause. Both are taken when the job is created, so a job deferred
50
+ to after the commit (`enqueue_after_transaction_commit`) keeps them, and travel in its payload
51
+ (`lyra_correlation_id`, `lyra_causation_id`), so a retry does too. A job created inside a
52
+ transaction and sent at once (the Rails default) could outlive a rollback of its cause; its
53
+ cause is kept when it runs only if that event is in the log, so no job names a cause the log
54
+ lacks (one that runs before the enqueuing transaction commits loses the link).
55
+ Monitor, Hijack and event-sourcing events alike (`DomainEvents.build` takes the pre-assigned
56
+ `event_id:`, given once per write). The Olist replay runs each step as one request would
57
+ (`test/write_correlation_test.rb`).
58
+
59
+ ### Changed
60
+ - **ORFEAS is expanded as the words its letters stand for**: Object-Relational Framework for
61
+ Event-Aware Systems (README, `docs/ARCHITECTURE.md` and its citation, gem descriptions). It
62
+ read "Object-Relational to Event-Sourcing Architecture", which has no F.
63
+
64
+ ### Fixed
65
+ - **Writes that skip callbacks name who made them.** Events of `update_columns`, `touch`,
66
+ `delete`, `update_all`, `delete_all`, `insert_all`, `upsert_all` and `dependent: :nullify`, and
67
+ `has_and_belongs_to_many` links, carried correlation and causation but neither `user_id` nor
68
+ `request_id`, so the same request's writes named a user only if they ran callbacks. They now
69
+ take both from `Current`, as the documentation already said. Events written before this keep no
70
+ actor; their correlation still ties them to the request's other writes.
71
+ - **`AsyncProjectionJob` warns again when its event is not in the log.** The warning sat in a
72
+ rescue of `RubyEventStore::EventNotFound`, which `read.event` never raises (it returns nil;
73
+ `read.event!` raises), so it was never logged. The job still did nothing for a missing event.
74
+
75
+ ## [0.9.0] - 2026-10-25
11
76
 
12
77
  Everything since 0.8.0, the version the PAM article describes.
13
78
 
79
+ ### Released
80
+ - The gems on RubyGems (`orfeas_lyra`, `orfeas_petri_flow`, `orfeas_pam_dsl`), built from tag
81
+ `v0.9.0` (2026-10-19); the entries marked *after the tag* are not in them
82
+ - The replication package: https://doi.org/10.5281/zenodo.17770613
83
+ - The benchmark results: https://doi.org/10.5281/zenodo.23640846
84
+
14
85
  ### Added
15
86
  - **`config.models` takes namespace patterns, `:all` and a denylist** (`Lyra::ModelSelection`):
16
87
  `config.models = %w[Spree::* Order]`, `config.models = :all`,
@@ -344,6 +415,14 @@ Everything since 0.8.0, the version the PAM article describes.
344
415
  YAML serializer) was not affected. Found by replaying the BPI Challenge 2017 log
345
416
  (`examples/bpi2017_loan_app`), whose timestamps carry milliseconds.
346
417
  ### Changed
418
+ - **Documentation** (*after the tag*) — `docs/PERFORMANCE.md` gives the 0.9.0 measurement (three runs, with
419
+ controls for PaperTrail and for the lock on the aggregate), the SQL statements per write under the
420
+ lock, and the published runs (https://doi.org/10.5281/zenodo.23640846); the install instructions
421
+ take the gems from RubyGems (`"~> 0.9"`); the case studies, benchmark harness and formal-model
422
+ scripts are referred to as the replication package (https://doi.org/10.5281/zenodo.17770613).
423
+ Comments no longer cite issue numbers; `lib/` changes in comments only.
424
+ - **The Rakefile's testbed tasks** (*after the tag*) say where the testbed is when it is absent, and `stats:all`
425
+ skips it.
347
426
  - **ES-NoProj reads**: a stale cache entry is brought up to date from the events after its stamp
348
427
  instead of a full replay; cache entries have a process-local copy in front of the shared cache;
349
428
  an association's records are read when first needed, and a reference-link lookup intersects
@@ -454,7 +533,10 @@ ignored like any unknown option.
454
533
  write path always uses `Lyra::CommandHandler`.
455
534
 
456
535
  ### Fixed
457
- - **A thread that wrote through Lyra kept a database connection until it ended (mpantel/lyra#37).** Lyra
536
+ - **`trace_conformance_test.rb` on a fresh database** (*after the tag*) — the recorder took the commit of Lyra's own
537
+ checkpoint table, created on a connection of its own, for part of the write; it now records only
538
+ the write's connection. Test only.
539
+ - **A thread that wrote through Lyra kept a database connection until it ended.** Lyra
458
540
  called `Model.connection` (Rails' `lease_connection`) in its write, lock and read paths: the
459
541
  aggregate lock, the id generator, Genesis, ES-Async's job, ES-NoProj's cache and reference links,
460
542
  ES-Lazy's catch-up, and its maintenance calls (DualView, Rebuild, mode checks, erasure). Since
@@ -470,7 +552,7 @@ ignored like any unknown option.
470
552
  returns whatever its work leased. `test/connection_release_test.rb`: in every mode, a thread that
471
553
  writes and reads, bare or in `with_connection`, ends without a connection, and `lib/` has no
472
554
  `Model.connection` call left.
473
- - **Genesis and the reference-link catch-up assumed a second connection (#37).** Inside an open
555
+ - **Genesis and the reference-link catch-up assumed a second connection.** Inside an open
474
556
  transaction both ran their work on a thread that checked out a connection of its own and waited
475
557
  for it. Under Rails' transactional tests the pool's connection is pinned, every checkout returns
476
558
  the caller's own, locked connection, and the wait never ended: the Aegean testbed suite in
@@ -491,7 +573,7 @@ ignored like any unknown option.
491
573
  association's links with the join table, `Rebuild` restores join tables from the events, and
492
574
  Genesis records the links pre-existing rows had. Under ES-NoProj, HABTM writes failed before
493
575
  reaching Lyra (`CachedRelation` lacked `where_values_hash`); they now work and read back
494
- (`test/habtm_links_test.rb`, every mode; mpantel/lyra#21).
576
+ (`test/habtm_links_test.rb`, every mode).
495
577
  - **Optimistic locking: a stale update silently won under event sourcing.** Active Record checks
496
578
  `lock_version` in the UPDATE or DELETE that writes the row; ES-Sync, ES-Async, ES-NoProj and
497
579
  ES-Lazy skip that statement, so a stale `update` or `destroy` overwrote the newer write (a lost
@@ -503,7 +585,7 @@ ignored like any unknown option.
503
585
  model in every event-sourcing mode), and raise `ActiveRecord::StaleObjectError` as Active Record
504
586
  does (`Lyra::OptimisticLocking`). A record that expects a newer version than the log's passes,
505
587
  so streams written before the change keep working (`test/optimistic_locking_test.rb`, every
506
- mode, concurrent writers included; mpantel/lyra#20).
588
+ mode, concurrent writers included).
507
589
  - **Encrypted attributes (Active Record `encrypts`) reached the event log in clear** while their
508
590
  row held ciphertext: events are built from attributes Active Record has already decrypted.
509
591
  `Lyra.append_events`, through which every event passes, now stores those attributes' values (in
@@ -511,7 +593,7 @@ ignored like any unknown option.
511
593
  (`Lyra::EncryptedAttributes`); projections, rebuilds and DualView decrypt them, and records
512
594
  instantiated from events are decrypted by Active Record. Events written before keep their clear
513
595
  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).
596
+ encrypted twice (`test/encrypted_attributes_test.rb`).
515
597
  - **ES-Lazy stopped projecting after the event store was emptied or restored without its
516
598
  checkpoint table**: the checkpoint then lay past the newest event, and every new event looked
517
599
  applied. A checkpoint past the newest event is now recognised, warned about and reset (applying
@@ -589,8 +671,8 @@ ignored like any unknown option.
589
671
  are now the same whether one mode or all are generated (`monitor_mode_workflow.rb` defines
590
672
  `MonitorModeWorkflow`, `es_sync_mode_workflow.rb` `EsSyncModeWorkflow`;
591
673
  `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`
674
+ - **`already initialized constant Lyra::VERSION` in a checkout holding a nested copy of the
675
+ engine** — Bundler's default gemspec search also loaded the nested copy's `lyra.gemspec`. The root `Gemfile`
594
676
  uses `gemspec glob: "lyra.gemspec"`, and the example apps' Gemfiles `glob: "lyra.gemspec"`.
595
677
  - **Hijack and event-sourcing events lost who wrote them** — the command handler wrote only
596
678
  the causal chain, so `user_id`, `request_id`, the user action and `config.metadata_proc`
@@ -651,11 +733,11 @@ ignored like any unknown option.
651
733
  (`CachedRelationDelivery`), as are the class-level `find`, `find_by` and `find_by!`.
652
734
  - **The all-modes runner skipped ES-Lazy** — `lyra:test:all_modes` and the PAM-less run now cover
653
735
  all seven configurations.
654
- - **Lyra's rake tasks ran twice, and host apps got the monorepo's maintenance tasks** — Rails
736
+ - **Lyra's rake tasks ran twice, and host apps got the development checkout's maintenance tasks** — Rails
655
737
  loads every `lib/tasks/*.rake` under an engine's root, and the engine also loaded its three
656
738
  task files explicitly, so each `lyra:mode:*`, `lyra:projections:*` and `lyra:schema:*` task
657
739
  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,
740
+ the other maintenance tasks appeared in every host app. The engine now loads `lyra_*.rake` only,
659
741
  once.
660
742
  - **`lyra:mode:check` could certify a switch having checked nothing** — monitored models register
661
743
  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
@@ -52,13 +52,14 @@ seven configurations:
52
52
  - PostgreSQL. Lyra depends on the `pg` gem; the advisory locks behind Genesis
53
53
  and ES-Lazy, and id reservation in Hijack mode, need PostgreSQL.
54
54
 
55
- The gem names differ from the files to require:
55
+ The gems are on RubyGems, at 0.9.1 (2 November 2026). Their names differ from the
56
+ files to require:
56
57
 
57
58
  ```ruby
58
59
  # 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
60
+ gem "orfeas_lyra", "~> 0.9", require: "lyra"
61
+ gem "orfeas_pam_dsl", "~> 0.9", require: "pam_dsl" # optional, privacy features
62
+ gem "orfeas_petri_flow", "~> 0.9", require: "petri_flow" # optional, verification
62
63
  ```
63
64
 
64
65
  Create the event store tables with the RailsEventStore 3 generator
@@ -139,7 +140,7 @@ individual calls.
139
140
  - **[Architecture Overview](docs/ARCHITECTURE.md)** - System design and components
140
141
  - **[Switching Modes](docs/MIGRATION_GUIDE.md#switching-modes)** - Who holds the mode, and how to switch safely (the deploy-time rule)
141
142
  - **[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)
143
+ - **[Performance](docs/PERFORMANCE.md)** - What each mode costs and why (measured on Lyra 0.9.0, with the SQL statements per write)
143
144
  - **[Troubleshooting](docs/TROUBLESHOOTING.md)** - Common problems
144
145
  - **[Workflow Generator](docs/WORKFLOW_GENERATOR.md)** - The Petri net workflow models of Lyra's modes and their verification
145
146
  - **[Changelog](CHANGELOG.md)**
@@ -189,6 +190,18 @@ DOI: [10.1145/3575879.3576006](https://doi.org/10.1145/3575879.3576006)
189
190
  EGOVIS 2022, Lecture Notes in Computer Science 13429, Springer, pp. 18–31.
190
191
  DOI: [10.1007/978-3-031-12673-4_2](https://doi.org/10.1007/978-3-031-12673-4_2)
191
192
 
193
+ ### Code and data behind the papers
194
+
195
+ Each paper's code is a tag or commit of this repository; its data and the rest of its
196
+ replication material are on Zenodo. The case studies (the Aegean ePay testbed, Solidus
197
+ with the Olist orders, the BPI 2017 loan application) are in the replication packages.
198
+
199
+ | Paper | Code | Data and replication material |
200
+ |:------|:-----|:------------------------------|
201
+ | *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) |
202
+ | *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) |
203
+ | *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) |
204
+
192
205
  ### Research Areas
193
206
 
194
207
  - **Event Sourcing Patterns**: Formal models for CRUD-to-event transformation
@@ -269,7 +282,8 @@ If you use this software in academic research, please cite:
269
282
  author={Pantelelis, Michail},
270
283
  year={2026},
271
284
  note={Part of ORFEAS Framework},
272
- url={https://github.com/mpantel/lyra-engine}
285
+ url={https://github.com/mpantel/lyra-engine},
286
+ doi={10.5281/zenodo.17770613}
273
287
  }
274
288
  ```
275
289
 
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
@@ -89,12 +89,34 @@ module Lyra
89
89
  (Thread.current[:lyra_pending_writes] || []).each { |write| project(write) }
90
90
  end
91
91
 
92
+ # Projects under the lock AsyncProjectionJob holds per stream, taken
93
+ # in the writer's transaction (or one of its own) and held to its end.
94
+ # A job that had read the stream before this write committed would
95
+ # otherwise write the row after this projection, putting it back to
96
+ # the older value until the newer event's job ran. With the lock, this
97
+ # projection waits for a job in flight; a job that starts after it
98
+ # reads a stream that already holds this write's event.
92
99
  def project(write)
93
- Lyra::Projections::ModelProjection.project(write[:model_class], write[:operation], write[:result])
100
+ model_class = write[:model_class]
101
+ model_class.transaction do
102
+ lock_stream(model_class, write[:result])
103
+ Lyra::Projections::ModelProjection.project(model_class, write[:operation], write[:result])
104
+ end
94
105
  rescue => e
95
106
  Rails.logger.error("Lyra: Failed to ensure projection - #{e.message}")
96
107
  raise if Lyra.config.strict_projections
97
108
  end
109
+
110
+ # The same key AsyncProjectionJob#lock_stream! locks: the record's
111
+ # stream name, from the model id its event carries.
112
+ def lock_stream(model_class, result)
113
+ data = result.events&.first&.data
114
+ model_id = data && (data[:model_id] || data["model_id"])
115
+ return unless model_id
116
+
117
+ stream = Lyra.stream_name(model_class, model_id)
118
+ model_class.with_connection { |c| Lyra::AdvisoryLock.xact_lock(c, stream, purpose: "read-your-writes projection") }
119
+ end
98
120
  end
99
121
  end
100
122
 
@@ -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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis