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 +4 -4
- data/CHANGELOG.md +92 -10
- data/README.md +21 -7
- data/Rakefile +11 -3
- data/app/controllers/lyra/dashboard_controller.rb +2 -0
- data/app/views/lyra/dashboard/discrepancies.html.erb +64 -35
- data/lib/lyra/associations/habtm_links.rb +2 -2
- data/lib/lyra/bypass_events.rb +18 -5
- data/lib/lyra/command_handler.rb +5 -1
- data/lib/lyra/configuration.rb +7 -0
- data/lib/lyra/consistency/read_your_writes.rb +23 -1
- data/lib/lyra/correlation.rb +89 -0
- data/lib/lyra/domain_events.rb +7 -3
- data/lib/lyra/encrypted_attributes.rb +1 -1
- data/lib/lyra/engine.rb +9 -1
- data/lib/lyra/interceptors/crud_interceptor.rb +42 -2
- data/lib/lyra/projections/async_projection_job.rb +5 -4
- data/lib/lyra/projections/reference_links.rb +1 -1
- data/lib/lyra/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a75016e10096d61497bb69e58b2570d23ca9a99a8206377fe94216d1db26eb9
|
|
4
|
+
data.tar.gz: a139ef32e0dd3e83ba2c5ce1f182e6cb83360f2208faea20a1330536ee63edae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
593
|
-
search also loaded the nested
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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",
|
|
60
|
-
gem "orfeas_pam_dsl",
|
|
61
|
-
gem "orfeas_petri_flow",
|
|
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 (
|
|
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
|
-
|
|
304
|
-
|
|
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:
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
<%
|
|
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
|
|
83
|
-
<h3><%= @model_class.name %> #<%=
|
|
84
|
-
<span class="status
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
104
|
-
</
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
118
|
-
</
|
|
146
|
+
<% end %>
|
|
147
|
+
</ul>
|
|
119
148
|
</div>
|
|
120
149
|
</div>
|
|
121
|
-
|
|
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
|
|
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 =
|
|
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))
|
data/lib/lyra/bypass_events.rb
CHANGED
|
@@ -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|
|
data/lib/lyra/command_handler.rb
CHANGED
|
@@ -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:
|
|
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
|
|
data/lib/lyra/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/lyra/correlation.rb
CHANGED
|
@@ -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
|
data/lib/lyra/domain_events.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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