orfeas_lyra 0.8.0 → 0.9.0
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 +803 -1
- data/README.md +201 -27
- data/app/controllers/lyra/application_controller.rb +48 -0
- data/app/controllers/lyra/dashboard_controller.rb +0 -1
- data/app/views/lyra/dashboard/projections.html.erb +0 -4
- data/app/views/lyra/dashboard/verification.html.erb +8 -2
- data/app/workflows/es_async_mode_workflow.rb +24 -60
- data/app/workflows/es_sync_mode_workflow.rb +25 -50
- data/app/workflows/hijack_mode_workflow.rb +18 -33
- data/app/workflows/lifecycle_workflow.rb +1 -1
- data/app/workflows/monitor_mode_workflow.rb +12 -22
- data/config/routes.rb +4 -2
- data/lib/lyra/access_log.rb +121 -0
- data/lib/lyra/advisory_lock.rb +45 -0
- data/lib/lyra/aggregate.rb +25 -13
- data/lib/lyra/associations/event_aware.rb +3 -4
- data/lib/lyra/associations/habtm_links.rb +255 -0
- data/lib/lyra/bypass_events.rb +208 -0
- data/lib/lyra/command.rb +3 -0
- data/lib/lyra/command_handler.rb +164 -42
- data/lib/lyra/configuration.rb +217 -8
- data/lib/lyra/consistency/read_your_writes.rb +74 -23
- data/lib/lyra/domain_events.rb +157 -0
- data/lib/lyra/dual_view.rb +27 -6
- data/lib/lyra/dual_view_sampler.rb +33 -0
- data/lib/lyra/encrypted_attributes.rb +101 -0
- data/lib/lyra/engine.rb +67 -5
- data/lib/lyra/erasure.rb +316 -0
- data/lib/lyra/event.rb +120 -2
- data/lib/lyra/event_flow.rb +16 -6
- data/lib/lyra/event_serializer.rb +45 -0
- data/lib/lyra/event_store_adapter.rb +58 -62
- data/lib/lyra/genesis.rb +189 -0
- data/lib/lyra/id_generator.rb +42 -10
- data/lib/lyra/interceptors/association_interceptor.rb +123 -6
- data/lib/lyra/interceptors/crud_interceptor.rb +388 -71
- data/lib/lyra/interceptors/lazy_reads.rb +46 -0
- data/lib/lyra/mode_sync.rb +125 -0
- data/lib/lyra/mode_transition.rb +386 -0
- data/lib/lyra/model_selection.rb +160 -0
- data/lib/lyra/optimistic_locking.rb +88 -0
- data/lib/lyra/optional_dependency.rb +24 -0
- data/lib/lyra/own_connection.rb +54 -0
- data/lib/lyra/privacy/adapters/pam.rb +151 -0
- data/lib/lyra/privacy/gdpr_compliance.rb +50 -0
- data/lib/lyra/privacy/interface.rb +262 -0
- data/lib/lyra/privacy/pii_detector.rb +8 -10
- data/lib/lyra/privacy/pii_masker.rb +30 -13
- data/lib/lyra/privacy/policy_integration.rb +36 -141
- data/lib/lyra/projection.rb +30 -7
- data/lib/lyra/projections/association_conditions.rb +81 -0
- data/lib/lyra/projections/async_checkpoint.rb +57 -0
- data/lib/lyra/projections/async_projection_job.rb +68 -15
- data/lib/lyra/projections/cached_joins.rb +192 -0
- data/lib/lyra/projections/cached_projection.rb +278 -162
- data/lib/lyra/projections/cached_relation.rb +666 -85
- data/lib/lyra/projections/deferred_relation.rb +120 -0
- data/lib/lyra/projections/event_store_reader.rb +27 -4
- data/lib/lyra/projections/lazy_projection.rb +259 -0
- data/lib/lyra/projections/model_projection.rb +32 -34
- data/lib/lyra/projections/rebuild.rb +58 -29
- data/lib/lyra/projections/reference_links.rb +309 -0
- data/lib/lyra/purpose_bound_reads.rb +197 -0
- data/lib/lyra/repair.rb +114 -0
- data/lib/lyra/retention.rb +180 -0
- data/lib/lyra/schema/event_class_registrar.rb +2 -1
- data/lib/lyra/schema/validator.rb +14 -5
- data/lib/lyra/sti_stream_migration.rb +70 -0
- data/lib/lyra/strict_data_access.rb +225 -175
- data/lib/lyra/temporal.rb +128 -0
- data/lib/lyra/verification/bypass_workflow.rb +170 -0
- data/lib/lyra/verification/crud_lifecycle_workflow.rb +32 -6
- data/lib/lyra/verification/workflow_generator.rb +73 -179
- data/lib/lyra/version.rb +1 -1
- data/lib/lyra.rb +113 -17
- data/lib/tasks/lyra_mode.rake +89 -0
- data/lib/tasks/lyra_projections.rake +43 -0
- data/lib/tasks/lyra_schema.rake +4 -0
- data/lib/tasks/lyra_workflows.rake +28 -27
- metadata +36 -5
- data/lib/lyra/event_mapper.rb +0 -106
data/CHANGELOG.md
CHANGED
|
@@ -7,9 +7,811 @@ 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
|
|
11
|
+
|
|
12
|
+
Everything since 0.8.0, the version the PAM article describes.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`config.models` takes namespace patterns, `:all` and a denylist** (`Lyra::ModelSelection`):
|
|
16
|
+
`config.models = %w[Spree::* Order]`, `config.models = :all`,
|
|
17
|
+
`config.models = :all, except: %w[Session]`. Patterns and `:all` enrol only eligible models
|
|
18
|
+
(concrete, named, with a table and a single-column primary key, base classes of an STI
|
|
19
|
+
hierarchy), after eager-loading the application's code. The event store's tables, Lyra's own and
|
|
20
|
+
those of Solid Cache/Queue/Cable, Active Storage, Action Text, Action Mailbox and PaperTrail are
|
|
21
|
+
never enrolled; naming one fails the boot. A pattern matching nothing warns; `:all` outside
|
|
22
|
+
Monitor mode warns. On a Solidus application `:all` enrols 90 models, excludes 28 and leaves 48
|
|
23
|
+
abstract classes and STI subclasses to their bases (`test/model_selection_test.rb`).
|
|
24
|
+
- **`ReadYourWrites.with_guaranteed_read(each_write: true)`**: each write in the block is projected
|
|
25
|
+
as it is made, so reads later in the same block see it (by default the block projects when it
|
|
26
|
+
ends). For code that writes and reads back in one unit of work, as Solidus's checkout does
|
|
27
|
+
(a replay of 2,000 Olist orders through Solidus under ES-Async matched the oracle for 135 orders,
|
|
28
|
+
and for 1,992, as plain ActiveRecord does, with each step in such a block).
|
|
29
|
+
`test/projections/read_your_writes_async_test.rb`. The controller concern takes it per
|
|
30
|
+
controller, inherited by subclasses, so an application is adapted from an initializer:
|
|
31
|
+
`Spree::BaseController.include Lyra::Consistency::ControllerConcern` and
|
|
32
|
+
`Spree::BaseController.lyra_read_your_writes each_write: true`
|
|
33
|
+
(`test/consistency/controller_concern_test.rb`). `docs/API_REFERENCE.md` named the concern
|
|
34
|
+
`ReadYourWrites::ControllerConcern`, which does not exist; it is `Consistency::ControllerConcern`.
|
|
35
|
+
- **`config.lock_aggregate_before_append`** (default `true`): Hijack, ES-Sync and ES-Async lock
|
|
36
|
+
the aggregate before appending. Two writers of one record could append in one order and write the
|
|
37
|
+
row in the other, leaving the row at a value its log does not replay to; seen in the wild, 2
|
|
38
|
+
rounds of 120 with 8 threads on one record, and reproduced deterministically
|
|
39
|
+
(`test/concurrent_same_record_test.rb`). An update or destroy now locks the record's row before
|
|
40
|
+
its event is appended, held to the end of the transaction (assumption L of Theorem 5), in the
|
|
41
|
+
strength the write takes anyway (PostgreSQL `FOR NO KEY UPDATE` for an update, `FOR UPDATE` for
|
|
42
|
+
a destroy), so a write takes the locks plain ActiveRecord takes, in the same order, and adds no
|
|
43
|
+
deadlock. A first version took an advisory lock on the stream before the row lock; it deadlocked
|
|
44
|
+
against a writer that holds the row first (`record.lock!`, `with_lock`) and then updates it,
|
|
45
|
+
where plain ActiveRecord only makes the update wait. The advisory lock remains for a record with
|
|
46
|
+
no row. `test/aggregate_lock_deadlock_test.rb`: writes to several records in one order never
|
|
47
|
+
deadlock, opposite orders deadlock exactly as plain ActiveRecord does, a row lock held first
|
|
48
|
+
does not deadlock a plain writer. SQLite needs no lock: it serialises writers from the first
|
|
49
|
+
write, which is the append (`test/sqlite_same_record_test.rb`). ES-Async writers take the
|
|
50
|
+
advisory lock on the stream (they do not write the row, the projection job does, so they never
|
|
51
|
+
wait for the row's lock); it makes a record's events commit in append order, which the job's
|
|
52
|
+
checkpoint relies on (below). Creates, Monitor, ES-Lazy and ES-NoProj are unchanged
|
|
53
|
+
(`test/aggregate_lock_statements_test.rb`). Cost: an update issues three more SQL statements
|
|
54
|
+
(the lock, then the savepoint pair the append now needs), so Hijack and ES-Sync updates go from
|
|
55
|
+
5 to 8 statements and ES-Async's from 4 to 7, and a destroy one more; on one contended record
|
|
56
|
+
Hijack's throughput falls from about 90 to about 52 ops/s. Set `false` for the previous
|
|
57
|
+
behaviour and its numbers.
|
|
58
|
+
- **ES-Async's projection job applies only the events after the stream's checkpoint**
|
|
59
|
+
(`Lyra::Projections::AsyncCheckpoint`, table `lyra_async_checkpoints`, created on first use)
|
|
60
|
+
instead of replaying the record's whole stream, so a record's jobs cost what its history is
|
|
61
|
+
long, not its square. Exact because the lock above makes a record's commits follow its
|
|
62
|
+
appends; with the lock off, or without a usable checkpoint (its event gone), the job replays
|
|
63
|
+
the whole stream. A checkpoint older than the row only re-applies events the row already has,
|
|
64
|
+
which is harmless (`test/projections/async_checkpoint_test.rb`).
|
|
65
|
+
- `test/projections/cached_projection_concurrency_test.rb`: readers and writers of one record under
|
|
66
|
+
ES-NoProj see only states the stream had, never go back, and converge.
|
|
67
|
+
- **Reference links for ES-NoProj** (`Lyra::Projections::ReferenceLinks`, `config.reference_links`,
|
|
68
|
+
default `:auto`). An event that sets a `belongs_to` foreign key is also linked into the stream
|
|
69
|
+
`Model.foreign_key$value`, the way Rails Event Store links events by metadata. A lookup by that
|
|
70
|
+
key, including the dependent-association lookups Active Record makes on destroy, reads the
|
|
71
|
+
stream for candidates and keeps those whose current state still matches, so moved and destroyed
|
|
72
|
+
children drop out and the answer is exact, without rebuilding every record of the model.
|
|
73
|
+
`:auto` writes links in ES-NoProj only, so no other mode pays; `true` and `false` force it.
|
|
74
|
+
Events stored while linking was off are linked before a model's first lookup in a process, from
|
|
75
|
+
a checkpoint kept in the store (`$lyra_reference_links$Model`). On the Aegean testbed this made
|
|
76
|
+
an ES-NoProj delete about 30 times faster at 16 threads and about 250 times at 64.
|
|
77
|
+
- **`config.monitor_append_failure`** (opt-in; default `:log`, unchanged). With `:fail_write`,
|
|
78
|
+
Monitor fails a write whose event cannot be stored, raising `EventStoreUnavailableError` and
|
|
79
|
+
rolling the write back (bulk writes included), as Hijack and the event-sourcing modes always
|
|
80
|
+
do. Row and event already commit together; the setting decides only what a failed append
|
|
81
|
+
does. Validated on assignment.
|
|
82
|
+
- **Trace conformance test** (`test/verification/trace_conformance_test.rb`). It records the
|
|
83
|
+
steps Lyra takes on real writes in every mode (event built, applied to the aggregate, stored;
|
|
84
|
+
row written; commit) and replays each trace token by token on a PetriFlow net: the paper's
|
|
85
|
+
core net with the row write placed as each mode places it. Every trace must be a firing
|
|
86
|
+
sequence ending in the net's final place, each operation must build exactly one event of its
|
|
87
|
+
own type, and a trace in another order is rejected. It found that Hijack and the
|
|
88
|
+
event-sourcing modes apply the event to the aggregate before storing it, the order the formal
|
|
89
|
+
model now uses.
|
|
90
|
+
- **`LYRA_DISABLE_PETRI_FLOW=true`** — runs Lyra as without petri_flow, as
|
|
91
|
+
`LYRA_DISABLE_PAM_DSL=true` does for PAM DSL.
|
|
92
|
+
- **`lyra:workflows:generate OUTPUT_DIR=... REPORTS_DIR=...`** — write the workflow files and
|
|
93
|
+
the reports to other directories than `app/workflows/` and `reports/`.
|
|
94
|
+
- **Dashboard authorization** (`config.dashboard_authorization`) — a proc run on the engine's
|
|
95
|
+
controller before every action (`instance_exec`, and given the controller when it takes an
|
|
96
|
+
argument); truthy allows, falsy answers 403. Unset, the dashboard is open in development and
|
|
97
|
+
test only and refused everywhere else with a log line, since its privacy pages show a data
|
|
98
|
+
subject's personal data. Example: `->(controller) { controller.current_user&.admin? }`.
|
|
99
|
+
- **`mode=` and `projection_mode=` validate** — one of `Configuration::MODES` and the new
|
|
100
|
+
`Configuration::PROJECTION_MODES`; a string is taken as its symbol, anything else raises
|
|
101
|
+
`ArgumentError` naming the valid values.
|
|
102
|
+
- **`lyra:erase MAX_COPIES=n`** — passed to `Lyra::Erasure.erase!` as `max_copies:`; the task
|
|
103
|
+
also prints how many values were left as shared.
|
|
104
|
+
- **The blog example runs again** (`examples/blog_app`) — rebuilt for Rails 8.1,
|
|
105
|
+
RailsEventStore 3 and PostgreSQL, with `bin/setup` and a passing test suite.
|
|
106
|
+
- **ES-NoProj runs Solidus** — the Olist replay passes under `projection_mode :disabled`
|
|
107
|
+
(6 of 6 orders end as Olist records them). Association queries (`variant.prices.find_by`,
|
|
108
|
+
`.where`, `.find_or_create_by!`) run on the records the event store holds for the owner, and
|
|
109
|
+
`:through`, polymorphic (`as:`) and scoped associations are read correctly: a `:through`
|
|
110
|
+
association was filtered on a foreign key its target does not have. Calculations and plucks on
|
|
111
|
+
an association (`payment.refunds.sum(:amount)`) read the events instead of the empty table.
|
|
112
|
+
Building through an association or a `where` takes its conditions, as ActiveRecord's
|
|
113
|
+
`scope_for_create` does. `CachedRelation` gains `group` with grouped `count`/`sum`/`average`/
|
|
114
|
+
`minimum`/`maximum`, `extending`, class attributes (`discard_column`), records as condition
|
|
115
|
+
values (mapped to their ids) and the relation readers ActiveRecord's calculations ask for.
|
|
116
|
+
- **Erasure driven by the policy, opt-in** (`Lyra::Retention`, `config.retention_executor`,
|
|
117
|
+
`config.retention_anchors`, `bin/rails lyra:retention:apply [DRY_RUN=1] [MODELS=]`,
|
|
118
|
+
`Lyra::RetentionJob`) — applies the policy's retention rules: for each monitored model with a
|
|
119
|
+
rule, a record past its period gets the rule's `on_expiry` — `:anonymize` (Lyra::Erasure),
|
|
120
|
+
`:hard_delete` (erase, then destroy through the write path), `:soft_delete` (`deleted_at` /
|
|
121
|
+
`discarded_at` through the write path; skipped without one), `:archive` (skipped: no archive
|
|
122
|
+
is named). Attributes with a shorter period of their own are erased first; rule conditions get
|
|
123
|
+
the record; already-erased attributes are skipped, so runs are idempotent. The period runs
|
|
124
|
+
from `created_at` or the column named per model. Off by default; a dry run works either way.
|
|
125
|
+
The privacy interface gains `Policy#retention_rule`.
|
|
126
|
+
- **Erasure finds copies by value, and optionally everywhere** — `Lyra::Erasure` now also
|
|
127
|
+
replaces an erased value wherever the record's events copy it under another name (a payload's
|
|
128
|
+
`payer_email`). `everywhere: true` (`EVERYWHERE=1`) searches the whole log for the values:
|
|
129
|
+
other records' events are scrubbed, their rows get the value replaced where they hold it, and
|
|
130
|
+
each gets an `ErasureApplied`; `Result#copies` lists them. A value held by more than
|
|
131
|
+
`max_copies` other records (default 10) is shared by many people, not a copy, and is left
|
|
132
|
+
(`Result#shared_values` counts them): on the Olist replay a placeholder address shared by
|
|
133
|
+
every customer took all 1,954 addresses with it. Only direct identifiers (the policy's email,
|
|
134
|
+
phone, identifier, card, payment-token, IP, credential types) are searched for in other
|
|
135
|
+
records: a name, city or postal code can be another customer's by coincidence (erasing one
|
|
136
|
+
Olist customer's postal-code prefix took two other customers' addresses). Erasure writes rows with one `UPDATE` by id,
|
|
137
|
+
so a record the application marks read-only (a Solidus address once an order uses it) is
|
|
138
|
+
erased too.
|
|
139
|
+
- **Purpose-bound `pluck` and `pick`** — checked by the declared attributes they name (also
|
|
140
|
+
inside SQL fragments), with the model (`"Registration$*"`) as subject, so a purpose whose
|
|
141
|
+
consent the policy requires refuses them.
|
|
142
|
+
- **Purpose-bound reads** (`Lyra::PurposeBoundReads`, `Lyra.with_purpose`, `lyra_purpose` for
|
|
143
|
+
controllers and jobs, `config.reads_without_purpose`) — a read of a monitored model with a
|
|
144
|
+
privacy policy, made within a declared purpose, is checked through the policy's
|
|
145
|
+
`validate_access!` with the record as subject: every declared attribute the query loaded must be
|
|
146
|
+
allowed for the purpose, so loading more than the purpose needs is refused (data minimisation).
|
|
147
|
+
No setting turns it on: the purpose does. Reads with no purpose follow
|
|
148
|
+
`config.reads_without_purpose`: `:allow` (default), `:audit` (logged, and recorded as an audited
|
|
149
|
+
access with purpose `none` when the access log is on) or `:deny` (`PurposeRequiredError`).
|
|
150
|
+
Lyra's own reads (projections, bypass snapshots, Genesis, DualView, mode checks, repair,
|
|
151
|
+
erasure) are exempt; reads that load no model are not checked. Every monitored model gains an
|
|
152
|
+
`after_find`, which returns at once when no purpose is in scope and reads without one are
|
|
153
|
+
allowed.
|
|
154
|
+
- **Erasure executor** (`Lyra::Erasure.erase!`, `bin/rails lyra:erase MODEL= ID= REASON=
|
|
155
|
+
[FIELDS=]`, `Lyra::Events::ErasureApplied`) — erases one record's personal attributes (declared
|
|
156
|
+
by its policy or listed by its events' privacy stamps, or exactly `fields:`) from the row and
|
|
157
|
+
from every event in its stream, overwriting each event in place (same id, position, time):
|
|
158
|
+
attributes, both sides of each change, and payload keys with the attribute's name. Appends
|
|
159
|
+
`ErasureApplied` (fields, reason, who; never a value), which replay skips; the stream still
|
|
160
|
+
replays to the anonymized row. Replacement is nil where allowed, `"erased:<id>"` for a NOT NULL
|
|
161
|
+
string, else the column default. Works in every mode, with strict data access on and inside a
|
|
162
|
+
purpose. On request only; not policy-driven, and crypto-shredding remains future work.
|
|
163
|
+
- **Event store failure policies, named** (`Lyra::EventStoreUnavailableError`, `Lyra.append_events`;
|
|
164
|
+
FEATURE_GAP_PLAN F1) — every event Lyra writes now goes through `Lyra.append_events`, which
|
|
165
|
+
raises `EventStoreUnavailableError` (the store's error as its `cause`, the stream in its message)
|
|
166
|
+
when the store fails. The policy is fixed by the mode: **fail-closed** in Hijack and every
|
|
167
|
+
event-sourcing mode (the write fails with the error and rolls back), **log-and-continue** in
|
|
168
|
+
Monitor (the write stands; the log line names the stream and points to `lyra:repair`). No
|
|
169
|
+
in-memory retry queue: the store shares the application's database, a queue dies with its
|
|
170
|
+
process, and a retried event could land behind a later one in its stream.
|
|
171
|
+
- **Repair after lost events** (`Lyra::Repair`, `bin/rails lyra:repair [DRY_RUN=1] [MODELS=...]`) —
|
|
172
|
+
brings the event log back in line with the tables after Monitor lost events: an `Imported` event
|
|
173
|
+
for a row with no events (or one its stream says was destroyed), an `Updated` event with the
|
|
174
|
+
differing columns for a row that differs from its events, a `Destroyed` event for events with no
|
|
175
|
+
row. Each record is re-checked and repaired under a lock on its row; repair events carry
|
|
176
|
+
metadata `source: "lyra_repair"` and the problem repaired. Refused in Hijack and event sourcing,
|
|
177
|
+
where the events are authoritative (rebuild the tables instead). `ModeTransition.record_ids` and
|
|
178
|
+
`ModeTransition.discrepancy` expose the check it uses.
|
|
179
|
+
- **Access log** (`config.record_access_events`, default false; `Lyra::AccessLog`,
|
|
180
|
+
`Lyra::Events::DataAccessed`, `Lyra::Events::DataAccessDenied`; FEATURE_GAP_PLAN F6) — when on,
|
|
181
|
+
every access the privacy policy validates (`validate_access!`) is recorded in the subject's
|
|
182
|
+
access stream (`Lyra::DataAccess$<Model>$<id>`): `DataAccessed` when it went ahead (outcome
|
|
183
|
+
`granted`, or `audited` with the violations audit mode let through), `DataAccessDenied` when
|
|
184
|
+
strict mode refused it. Each carries the policy, purpose, legal basis, field names (never
|
|
185
|
+
values) and outcome. Who accessed goes in the metadata: `user_id` and `ip_address` from the
|
|
186
|
+
application's `Current.user` / `Current.ip_address`, request and correlation ids, and whatever
|
|
187
|
+
`config.access_metadata_proc` returns for the access (for consoles, jobs and API tokens that
|
|
188
|
+
`Current` does not know; a failing proc is logged and skipped). Every access is
|
|
189
|
+
recorded, with no sampling; an access the store cannot record does not go ahead. Nothing is
|
|
190
|
+
recorded in disabled mode, and the record's own stream is untouched. `AccessLog.for(subject)`
|
|
191
|
+
reads them back. Off by default because reads can outnumber writes by orders of magnitude; the
|
|
192
|
+
Aegean testbed turns it on with `LYRA_RECORD_ACCESS=1`, which also makes the benchmark's reads
|
|
193
|
+
validate access, and records it in its benchmark reports. The Article 30 register stays
|
|
194
|
+
PAM's `article_30_report`, generated from the declared policy.
|
|
195
|
+
- **Privacy stamps on events** (`config.annotate_privacy`, default false; `Lyra::Privacy.stamp`,
|
|
196
|
+
`Lyra::Privacy.stamp_of`; FEATURE_GAP_PLAN F5) — when on, every event that carries an
|
|
197
|
+
attribute the model's privacy policy declares is stamped as it is built with
|
|
198
|
+
`metadata[:privacy]`: the policy's name and, per attribute, its type, sensitivity, allowed
|
|
199
|
+
purposes, retention period (ISO 8601) and the contexts it has a transformation for — never a
|
|
200
|
+
value. Every declared attribute the event carries is annotated: a create's, import's or
|
|
201
|
+
destroy's attributes, and an update's changes together with the whole row Monitor also records
|
|
202
|
+
(annotating only the changes left 28,374 of an Olist replay's events carrying personal data
|
|
203
|
+
unannotated). All four places events are built stamp them: Monitor, the command handler (Hijack,
|
|
204
|
+
event sourcing, and so domain events), bypass events and Genesis imports. The stamp records the
|
|
205
|
+
policy as it was when the data was written, unlike `annotations_for`, which reads today's
|
|
206
|
+
policy; `stamp_of(event)` reads it back with string keys. Off by default because it adds work
|
|
207
|
+
to every write (no SQL statements); the Aegean testbed turns it on with
|
|
208
|
+
`LYRA_ANNOTATE_PRIVACY=1` and records it in its benchmark reports. The privacy `Annotation`
|
|
209
|
+
gains a `transformations` member.
|
|
210
|
+
- **ModeSync: one mode per application** (`Lyra::ModeSync`, `config.mode_sync`,
|
|
211
|
+
`config.mode_sync_interval`) — the latest switch recorded in `lyra_mode_transitions` is the
|
|
212
|
+
application's mode; every process adopts a newer one within `mode_sync_interval` seconds
|
|
213
|
+
(default 5), checking before each web request (Rack middleware), each background job and each
|
|
214
|
+
write to a monitored model, never inside an open transaction. A runtime switch
|
|
215
|
+
(`ModeTransition.to!`) used to change only the process that made it. `to!` now logs what the
|
|
216
|
+
switch reaches. On wherever the Mode Transition Safety gate is (not in the test environment);
|
|
217
|
+
the raw `config.mode =` setter is left alone. the migration guide (`docs/MIGRATION_GUIDE.md`, Switching modes) documents the
|
|
218
|
+
deploy-time rule.
|
|
219
|
+
- **Mode Transition Safety** (`Lyra::ModeTransition`, `rake lyra:mode:check`; FEATURE_GAP_PLAN F3)
|
|
220
|
+
— a mode switch that changes the authoritative store is allowed only when rows and events
|
|
221
|
+
agree for every monitored record: escalating from Disabled or Monitor to Hijack or event
|
|
222
|
+
sourcing (rows that predate Lyra are imported by Genesis first), and leaving ES-NoProj,
|
|
223
|
+
ES-Lazy or ES-Async for a mode that reads tables (`rebuild: true` rebuilds them first). The
|
|
224
|
+
check compares every row and every stream in batches, reading the real table, then
|
|
225
|
+
re-checks what changed while it ran, and stores a clean result as a certificate.
|
|
226
|
+
`ModeTransition.to!` and the `enable_*!` helpers accept a fresh certificate after
|
|
227
|
+
re-checking only what changed since, else run the full check, and raise `Refused` with the
|
|
228
|
+
discrepancies; `force: true` overrides. At boot, a switch from the mode the application last
|
|
229
|
+
ran in needs a certificate (`LYRA_FORCE_MODE_TRANSITION=1` overrides; rake processes are
|
|
230
|
+
exempt). `config.mode_transition_gate`: nil (default) gates everywhere but the test
|
|
231
|
+
environment; `config.mode = ...` stays the raw, ungated setter.
|
|
232
|
+
- **Sampled DualView verification** (`config.dual_view_sample_rate`, default 0.0, off;
|
|
233
|
+
`config.dual_view_discrepancy_handler`) — after a write commits, a sampled share of writes
|
|
234
|
+
is compared with its events; a discrepancy is logged and passed to the handler, and the write
|
|
235
|
+
is never failed. Skipped in ES-NoProj, ES-Lazy and ES-Async, whose tables lag by design.
|
|
236
|
+
- **Domain events** (`monitor_with_lyra domain_events: [...]`, `Lyra::DomainEvents`;
|
|
237
|
+
FEATURE_GAP_PLAN F8) — rules name a write's event after what it means instead of the CRUD
|
|
238
|
+
operation: `{ name: "PaymentCompleted", on: %i[create update], if: ->(payment, changes) { ... } }`.
|
|
239
|
+
The first matching rule names the write's own event, which keeps Lyra's envelope (model, id,
|
|
240
|
+
operation, attributes, changes), so replay, projections, DualView and `Lyra.state_at` work
|
|
241
|
+
unchanged; a write no rule matches keeps its CRUD event. `payload:` adds fields for the
|
|
242
|
+
event's consumers under `data[:payload]`; `class:` uses an event class of your own (a
|
|
243
|
+
`RubyEventStore::Event` subclass) instead of a generated `Lyra::Events::<name>`;
|
|
244
|
+
`also: true` emits an additional event from the same write, in the same stream and
|
|
245
|
+
transaction, marked `replay: false` and pointing to the write's own event through its
|
|
246
|
+
`causation_id`. Works in Monitor, Hijack and every event-sourcing mode; writes that skip
|
|
247
|
+
callbacks are still recorded as CRUD bypass events. Generated event classes are registered
|
|
248
|
+
at boot. A rule whose block raises fails the write where events are the record (Hijack,
|
|
249
|
+
event sourcing) and is logged in Monitor. Commands now carry their `record`.
|
|
250
|
+
- **Configuration surface** (FEATURE_GAP_PLAN F2): `config.models = %w[Order Payment]` (or a
|
|
251
|
+
hash of name => options) declares the monitored models by name in the initializer; once the
|
|
252
|
+
application's code has loaded, each is given `monitor_with_lyra`, with no model file edited,
|
|
253
|
+
and a name that does not resolve fails the boot. `config.verify_mapping!` verifies the
|
|
254
|
+
CRUD-to-event mapping at boot (`Lyra.verify_mapping!`: the PetriFlow lifecycle, mode and
|
|
255
|
+
bypass nets through `CrudVerifier`, and a table and primary key for every monitored model)
|
|
256
|
+
and stops the boot with `Lyra::MappingVerificationError` naming each failed check; called
|
|
257
|
+
after boot it runs at once. `config.privacy_policy = :name` is the default policy for
|
|
258
|
+
monitored models that name none of their own.
|
|
259
|
+
- **Point-in-time reconstruction** (`Lyra.state_at(model, id, time)`, `Model.as_of(time)`,
|
|
260
|
+
`Lyra::Temporal`) — a record's attributes at a given time, rebuilt by replaying its stream up
|
|
261
|
+
to the last event stored at or before then: `nil` if it did not exist yet or had been
|
|
262
|
+
destroyed. `Model.as_of(time).find(id)` / `.find_by_id(id)` return read-only records and
|
|
263
|
+
`.all` every record that existed then. For a row imported by Genesis, a time before its
|
|
264
|
+
import raises `Lyra::Temporal::HistoryNotRecorded` instead of guessing (a time before its own
|
|
265
|
+
`created_at` is `nil`). Works in every mode that records events, Monitor included
|
|
266
|
+
(FEATURE_GAP_PLAN F7).
|
|
267
|
+
- **ES-NoProj joins, evaluated in memory** (`Lyra::Projections::CachedJoins`) — `joins`,
|
|
268
|
+
`left_joins`/`left_outer_joins`, `where.missing` and `where.associated` on `belongs_to`,
|
|
269
|
+
`has_many` and `has_one` associations, with hash conditions on the joined table
|
|
270
|
+
(`where(programs: { available: true })`, by table or association name), used to raise
|
|
271
|
+
`UnsupportedQuery`. They are now evaluated as SQL would: a joined relation holds rows of a
|
|
272
|
+
record and its partners; an inner join drops records without a partner; a `has_many` join
|
|
273
|
+
repeats the record per partner (so `count` agrees, and `distinct` removes the repeats); a
|
|
274
|
+
missing left-join partner behaves like NULL, including under `where.not`. Partners come from
|
|
275
|
+
the joined model's streams if it is an ES-NoProj model, otherwise from one SQL query on its
|
|
276
|
+
table. Still refused: SQL-string, nested, `:through`, polymorphic and scoped joins, a
|
|
277
|
+
condition on a table not joined first, and `merge` with an arbitrary relation (so Solidus
|
|
278
|
+
still needs ES-Lazy). `where` with no arguments now returns the where-chain, as in
|
|
279
|
+
ActiveRecord.
|
|
280
|
+
- **Privacy provider interface** (`Lyra::Privacy`) — `Policy` (declared attributes and their
|
|
281
|
+
`Annotation`s), `Detector` (name-based PII heuristic) and `Provider`, with null defaults when
|
|
282
|
+
no provider is installed. PAM is an adapter (`Lyra::Privacy::Adapters::Pam`), loaded when
|
|
283
|
+
`pam_dsl` is present. `Lyra::Privacy.annotations_for(event)` returns the annotation of every
|
|
284
|
+
declared attribute an event touched. The foundation for `config.privacy_policy=` and PII
|
|
285
|
+
annotation in event metadata (`FEATURE_GAP_PLAN.md`, F2 and F5). From `thesis-restructure`
|
|
286
|
+
(1adadfe); the testbed binds `Registration` and `PaymentTransaction` to its `:epay_data`
|
|
287
|
+
policy (d0df542), which adds nothing to the write path.
|
|
288
|
+
- **`Lyra::IdGenerator.reserves_safely?(model)`** — true for UUID keys and for integer keys
|
|
289
|
+
backed by a PostgreSQL sequence. It uses the cached sequence lookup, so it adds no query per
|
|
290
|
+
write once a table's sequence is known.
|
|
291
|
+
- **Bypass events for bulk writes** (`Lyra::BypassEvents`) — `update_all`, `delete_all`,
|
|
292
|
+
`insert`/`insert_all(!)`, `upsert`/`upsert_all` and `dependent: :nullify` on a monitored model
|
|
293
|
+
now publish one event per affected row, whatever the `strict_data_access` setting
|
|
294
|
+
(`update_column(s)`, `touch` and `delete` already did). `update_all` publishes "updated" per
|
|
295
|
+
row that changed; `delete_all` "destroyed" per row; inserts "created" per row; `upsert_all`
|
|
296
|
+
finds existing rows by its conflict key (primary key, `unique_by:` columns or index name) and
|
|
297
|
+
publishes "updated" for those, "created" for the rest. The caller's `returning:` result is
|
|
298
|
+
unchanged. Brought over from the `thesis-restructure` branch (3ba39bb, 975dc6e), keeping
|
|
299
|
+
master's instance methods (events built after the write).
|
|
300
|
+
- **`Lyra.projection_write { ... }`** — Lyra's own read-model writes: skips strict data access
|
|
301
|
+
and publishes no bypass events. `ModelProjection` and `Rebuild` use it; use it for writes that
|
|
302
|
+
must not reach the event stream, such as seeding or wiping a table.
|
|
303
|
+
- **Genesis: event streams for rows that predate Lyra** (`Lyra::Genesis`,
|
|
304
|
+
`config.genesis`, `rake lyra:genesis`) — a row written before Lyra was enabled had no
|
|
305
|
+
stream, so its first `Updated` event started a stream with no head, and DualView,
|
|
306
|
+
`Rebuild` and ES-NoProj reads saw the record wrongly or not at all. On a model's first
|
|
307
|
+
use in a process (its first write, or in ES-NoProj its first read or aggregate), every
|
|
308
|
+
row without a stream now gets one `Imported` event holding the row as it is. `Imported`
|
|
309
|
+
is replayed like `Created` everywhere state is rebuilt. Later uses cost nothing; when
|
|
310
|
+
every row already has a stream the check is one query and takes no lock. The import
|
|
311
|
+
runs under a per-model advisory lock on a connection of its own, so a long caller
|
|
312
|
+
transaction does not block other first uses and a caller's rollback does not undo it.
|
|
313
|
+
`config.genesis`: `:auto` (default, event-sourcing mode only), `true` (also Monitor and
|
|
314
|
+
Hijack) or `false`. `rake lyra:genesis MODEL=...` imports ahead of time for large tables.
|
|
315
|
+
- **ES-NoProj SQL aggregates computed from streams only, with SQL semantics** —
|
|
316
|
+
`count(column)`, `sum`, `average`, `minimum`, `maximum` and `calculate` on an ES-NoProj
|
|
317
|
+
relation run over the records rebuilt from streams (rows that predate Lyra included, via
|
|
318
|
+
Genesis). `sum` used to go through `to_f` (losing decimal precision) and now keeps the
|
|
319
|
+
column's type, returning 0 over no rows; `average` returns a `BigDecimal` for integer and
|
|
320
|
+
decimal columns, as ActiveRecord does; `count(column)` counts non-NULL values, where it
|
|
321
|
+
used `present?` and dropped `""` and `false`. An SQL expression (`sum("price * qty")`)
|
|
322
|
+
raises `UnsupportedQuery` instead of being guessed. Known difference: `average` keeps
|
|
323
|
+
every digit of the quotient, while PostgreSQL rounds a numeric `AVG` to about 16
|
|
324
|
+
significant digits.
|
|
325
|
+
- **ES-Lazy: event sourcing that projects on read** (`projection_mode :lazy`, in
|
|
326
|
+
event-sourcing mode; `Lyra::Projections::LazyProjection`, `Lyra::Interceptors::LazyReads`)
|
|
327
|
+
— writes store events only, as in ES-NoProj; before any database read, the tables are
|
|
328
|
+
brought up to date from the event log, and the read runs as real SQL, so joins, merged
|
|
329
|
+
relations, SQL fragments and aggregates all work. The log stays the only source of
|
|
330
|
+
truth; the tables are a cache that `Rebuild` can recreate. Events are applied in the
|
|
331
|
+
log's global order under a PostgreSQL advisory lock, against a checkpoint that also
|
|
332
|
+
tracks ids still in flight (filled by replaying the record when they commit, forgotten
|
|
333
|
+
after 5 minutes). Read-your-writes consistent. Needs no migration: the checkpoint table
|
|
334
|
+
is created on first use. It runs Solidus, which ES-NoProj cannot: on 20 real Olist
|
|
335
|
+
orders it matches plain ActiveRecord with DualView clean. Cost: every read first checks
|
|
336
|
+
the log (about half ES-Sync's throughput in the replays).
|
|
337
|
+
- **`Lyra::EventSerializer`** — a JSON serializer for RailsEventStore that keeps time
|
|
338
|
+
precision. Ruby's `JSON` writes `Time` with `Time#to_s`, dropping fractional seconds, so
|
|
339
|
+
apps passing `serializer: JSON` stored `09:51:15.304` as `"09:51:15 UTC"`, and an update
|
|
340
|
+
from `.304` to `.352` as no change at all. `Lyra::EventSerializer` writes times as ISO 8601
|
|
341
|
+
with microseconds and `BigDecimal` as its exact decimal string; `load` is plain
|
|
342
|
+
`JSON.parse`. Opt in with
|
|
343
|
+
`EventRepository.new(serializer: Lyra::EventSerializer)`. Lyra's default client (RES's
|
|
344
|
+
YAML serializer) was not affected. Found by replaying the BPI Challenge 2017 log
|
|
345
|
+
(`examples/bpi2017_loan_app`), whose timestamps carry milliseconds.
|
|
346
|
+
### Changed
|
|
347
|
+
- **ES-NoProj reads**: a stale cache entry is brought up to date from the events after its stamp
|
|
348
|
+
instead of a full replay; cache entries have a process-local copy in front of the shared cache;
|
|
349
|
+
an association's records are read when first needed, and a reference-link lookup intersects
|
|
350
|
+
every indexed key and takes candidate ids from the stream table; `Model.new` reads no record.
|
|
351
|
+
A replay of 640 Olist orders through Solidus took 855 s where it took 2,229 s, with the same
|
|
352
|
+
end state.
|
|
353
|
+
- **`ModeTransition.to!` links before switching into ES-NoProj.** Entering `event_sourcing`
|
|
354
|
+
with `projection_mode: :disabled` first runs a full reference-link pass over the monitored
|
|
355
|
+
models, so lookups are complete from the first one.
|
|
356
|
+
- **Documentation regrouped.** `docs/ADOPTION.md` and `docs/MODE_TRANSITIONS.md` are now
|
|
357
|
+
sections of `docs/MIGRATION_GUIDE.md` (Adopting Lyra in an existing application; Switching
|
|
358
|
+
modes); `docs/ORFEAS_FRAMEWORK_OVERVIEW.md` is the opening of `docs/ARCHITECTURE.md` (About
|
|
359
|
+
ORFEAS); `docs/PROJECTIONS_DATA_ACCESS_MONITORING.md` is split between `ARCHITECTURE.md`
|
|
360
|
+
(projections, write-side monitoring) and `docs/PRIVACY_COMPLIANCE.md` (read-side monitoring).
|
|
361
|
+
Claims were checked against the code on the way. `docs/PERFORMANCE.md` no longer presents the
|
|
362
|
+
withdrawn July 2026 figures: it explains each mode's cost in SQL statements per write, and
|
|
363
|
+
the throughput is being re-measured.
|
|
364
|
+
- **The workflow generator's mode nets name what Lyra does.** Their steps were labelled with
|
|
365
|
+
methods that do not exist (`CommandHandler.validate`, `CommandHandler.execute`,
|
|
366
|
+
`Projection.apply`, `EventStore.append_to_stream`) or with `Aggregate.apply(command)`, and
|
|
367
|
+
the Monitor net called the event store through `Lyra::Event.publish`. Each step now names the
|
|
368
|
+
method Lyra calls, in the order the trace conformance test observes; the generator no longer
|
|
369
|
+
claims to introspect the implementation (the mode nets are written by hand). The shipped
|
|
370
|
+
`app/workflows/*_mode_workflow.rb` files are regenerated.
|
|
371
|
+
- **Ruby 4.0 or later is required** (`required_ruby_version >= 4.0`, was `>= 3.4.5`); CI tests
|
|
372
|
+
Ruby 4.0. The project runs 4.0.7.
|
|
373
|
+
- **Article 30 register (PAM)** — `pam_dsl:report:article_30` states only what the policy declares (data subjects, recipients, transfers, security measures) and lists the rest as not declared; see `gems/pam_dsl/CHANGELOG.md`.
|
|
374
|
+
- **PII detection (PAM)** — Lyra's detector fallback (`Lyra::Privacy::PIIDetector`, `PolicyIntegration`) uses PAM's merged dictionary: more personal columns found (street, postal code, IP columns, card digits, account tokens), foreign keys and generic `*_name` columns no longer flagged. See `gems/pam_dsl/CHANGELOG.md`.
|
|
375
|
+
- **Hijack fails a write whose event cannot be stored with `EventStoreUnavailableError`**, as event
|
|
376
|
+
sourcing does, instead of a refused save (`save` returning false with the store's message on
|
|
377
|
+
`errors[:base]`). A store failure is not a validation error; `save` now raises it, as it raises
|
|
378
|
+
database errors.
|
|
379
|
+
- **ES-Async can run out of line in tests** — `async_projections_inline` now decides: unset
|
|
380
|
+
(the new default, `nil`) projects inline in the test environment only, as before; `true`
|
|
381
|
+
always; `false` never, so a test with an `:async` job adapter exercises the real background
|
|
382
|
+
path. The test environment used to force inline projection whatever the setting said, so no
|
|
383
|
+
test could run ES-Async as it runs in production. `Configuration#async_projections_inline?`
|
|
384
|
+
gives the effective value.
|
|
385
|
+
- **ES-NoProj cache: entries stamped with their stream's last event** (`CachedProjection`) — each
|
|
386
|
+
record's cached rebuild carries the id of the last event it was built from and is used only
|
|
387
|
+
while that is still the stream's last event, so it is exactly right or detectably out of date.
|
|
388
|
+
Collection reads (`all`, `where`, `find_by` on non-key attributes, `count`) are assembled from
|
|
389
|
+
those entries: one query for every stream's last event, one bulk cache read, and a replay of
|
|
390
|
+
only the streams that changed. They used to be cached as whole collections that every write
|
|
391
|
+
threw away, so after any write the next collection read replayed every stream of the model.
|
|
392
|
+
A `dependent: :nullify` delete, which must find the children by attribute, cost 1.7-3.5 s in
|
|
393
|
+
the Aegean smoke runs; it now costs 0.1-0.4 s (that cell: 10-16 → 49-101 ops/s). ES-NoProj
|
|
394
|
+
SQL statements per write: 12 → 11 per create, 9 → 8 per update (a write no longer deletes
|
|
395
|
+
collection keys; it reads its stream's last event). Cache entries are versioned `v2`; old
|
|
396
|
+
ones are ignored.
|
|
397
|
+
- **`Lyra.privacy_features_available?`** asks the installed privacy provider instead of
|
|
398
|
+
checking for `pam_dsl` directly. `PIIDetector` and `PolicyIntegration` load always and work
|
|
399
|
+
through the interface; `PIIMasker` and `GDPRCompliance` stay PAM-only.
|
|
400
|
+
- **Hijack creates reserve an id only where it cannot collide** (`reserves_safely?`). Elsewhere
|
|
401
|
+
(an integer key without a PostgreSQL sequence, e.g. SQLite or MySQL) hijack falls back to the
|
|
402
|
+
`pending-<hex>` placeholder with a one-time warning, rather than guessing an id the database
|
|
403
|
+
may also hand out. On PostgreSQL nothing changes, and SQL statements per write are unchanged
|
|
404
|
+
in every mode.
|
|
405
|
+
- **Bulk writes on monitored models publish events** (see Added). Code that relied on
|
|
406
|
+
`update_all`/`delete_all`/`insert_all` leaving the event stream untouched should wrap them in
|
|
407
|
+
`Lyra.projection_write`. Bulk-write guards are on `ActiveRecord::Relation`
|
|
408
|
+
(`Lyra::StrictDataAccessRelation`); `Lyra::StrictDataAccessClassMethods` is removed.
|
|
409
|
+
- **ES-NoProj bulk writes are events.** With projections disabled the records exist only in
|
|
410
|
+
the stream, so `CachedRelation#delete_all`/`update_all` publish the events that are the write
|
|
411
|
+
(a `delete_all`d record used to reappear on the next read). `upsert_all` raises
|
|
412
|
+
`ArgumentError` there: its `ON CONFLICT` would check the table, not the stream. Cost: a
|
|
413
|
+
`dependent: :nullify` delete must find the children, which in ES-NoProj rebuilds every stream
|
|
414
|
+
of the child model.
|
|
415
|
+
- Instance bypass events (`update_column(s)`, `touch`, `delete`) go through
|
|
416
|
+
`BypassEvents.publish`, which also invalidates the ES-NoProj read cache.
|
|
417
|
+
- **ES-NoProj answers exactly or raises** (`Lyra::Projections::CachedRelation`, new
|
|
418
|
+
`Lyra::Projections::UnsupportedQuery`) — reading from the event store, `CachedRelation`
|
|
419
|
+
used to answer queries it could not evaluate anyway, with the wrong records: a
|
|
420
|
+
SQL-string `where` kept every record, unrecognised conditions (`!=`, ranges written as
|
|
421
|
+
SQL, ORs) were dropped, `joins` and `where.missing`/`where.associated` were ignored, a
|
|
422
|
+
condition on an unknown column matched nothing, and a scope that failed, carried
|
|
423
|
+
`order`/`limit`/`group` and the like, or was not a scope at all, returned the whole set.
|
|
424
|
+
Each now raises `UnsupportedQuery`, naming the query and the alternatives. Hash
|
|
425
|
+
conditions, ordering, limits, loading hints and scopes made of hash conditions work as
|
|
426
|
+
before. This found the Aegean benchmark's own cleanup, `where("email LIKE ?")` under
|
|
427
|
+
ES-NoProj, silently matching every registration.
|
|
428
|
+
- **RailsEventStore 3** — `lyra.gemspec` now requires `rails_event_store ~> 3.0` (3.2.0 in the
|
|
429
|
+
lockfile), on Ruby 4.0.7 and Rails 8.1.4 (8.1.3.1 and RES 3.1.0 until 2026-10-19). RES 3 moved
|
|
430
|
+
the ActiveRecord repository to
|
|
431
|
+
`RubyEventStore::ActiveRecord::EventRepository` and the event base class and errors to
|
|
432
|
+
`RubyEventStore`; `Lyra::Event`, `Lyra::Aggregate` and the default client follow. Apps
|
|
433
|
+
that build their own client must switch from `RailsEventStoreActiveRecord::EventRepository`.
|
|
434
|
+
- **`IdGenerator` caches the PostgreSQL sequence name** — `pg_get_serial_sequence` ran on
|
|
435
|
+
every reserved ID, a round trip whose answer never changes. Since hijack mode now reserves
|
|
436
|
+
its IDs here too, that was one extra SQL statement on every hijack-mode create (two
|
|
437
|
+
instead of one). The name is now looked up once per table and cached; only `nextval`
|
|
438
|
+
runs per ID.
|
|
439
|
+
|
|
440
|
+
### Removed
|
|
441
|
+
Dead code, none of it on the write path. Initializers that set `config.event_backend`, or models
|
|
442
|
+
that pass `command_handler:`, must drop it: the former now raises `NoMethodError`, the latter is
|
|
443
|
+
ignored like any unknown option.
|
|
444
|
+
- **`Lyra::EventMapper` and `Lyra::AuditMapper`** (`lib/lyra/event_mapper.rb`). The interceptor
|
|
445
|
+
and the command handler build their events themselves (`CrudInterceptor#publish_event`,
|
|
446
|
+
`CommandHandler#create_event`, both through `Lyra::DomainEvents.build`); nothing called the
|
|
447
|
+
mappers.
|
|
448
|
+
- **`Lyra::EventStoreAdapter`, `RailsEventStoreAdapter`, `CustomEventStoreAdapter` and
|
|
449
|
+
`config.event_backend`**, read only by `EventStoreAdapter.build`. Lyra uses the RailsEventStore
|
|
450
|
+
client in `config.event_store` directly; `Lyra.append_events` and `EventStoreUnavailableError`
|
|
451
|
+
stay.
|
|
452
|
+
- **The `command_handler` model option** (`monitor_with_lyra command_handler:`,
|
|
453
|
+
`ModelConfiguration#command_handler`) and its dashboard line. It was stored and shown, but the
|
|
454
|
+
write path always uses `Lyra::CommandHandler`.
|
|
455
|
+
|
|
456
|
+
### Fixed
|
|
457
|
+
- **A thread that wrote through Lyra kept a database connection until it ended (mpantel/lyra#37).** Lyra
|
|
458
|
+
called `Model.connection` (Rails' `lease_connection`) in its write, lock and read paths: the
|
|
459
|
+
aggregate lock, the id generator, Genesis, ES-Async's job, ES-NoProj's cache and reference links,
|
|
460
|
+
ES-Lazy's catch-up, and its maintenance calls (DualView, Rebuild, mode checks, erasure). Since
|
|
461
|
+
Rails 7.2 that keeps the connection for the thread, even inside `with_connection`, which then does
|
|
462
|
+
not return it. So in Hijack and every event-sourcing mode, a thread of the application's own (a
|
|
463
|
+
job runner, a worker pool) ended still holding one, and the pool got it back only by reaping: the
|
|
464
|
+
first thread to find the pool full reset every dead thread's connection, one by one, while the
|
|
465
|
+
others waited. In the benchmark's 64-thread cells such a wait now and then passed the 5 s checkout
|
|
466
|
+
timeout (0.3 to 3 operations per trial of 12,800 in Hijack, Hijack+PT and ES-Sync; part of
|
|
467
|
+
ES-NoProj's 14 to 18), and the contention benchmark reset each round's connections inside its
|
|
468
|
+
timed window. Lyra now uses `with_connection` throughout: inside a transaction it is the
|
|
469
|
+
transaction's own connection, outside one it is returned after use. `OwnConnection`'s thread
|
|
470
|
+
returns whatever its work leased. `test/connection_release_test.rb`: in every mode, a thread that
|
|
471
|
+
writes and reads, bare or in `with_connection`, ends without a connection, and `lib/` has no
|
|
472
|
+
`Model.connection` call left.
|
|
473
|
+
- **Genesis and the reference-link catch-up assumed a second connection (#37).** Inside an open
|
|
474
|
+
transaction both ran their work on a thread that checked out a connection of its own and waited
|
|
475
|
+
for it. Under Rails' transactional tests the pool's connection is pinned, every checkout returns
|
|
476
|
+
the caller's own, locked connection, and the wait never ended: the Aegean testbed suite in
|
|
477
|
+
ES-NoProj deadlocked at its first indexed lookup. Under load each caller took a second
|
|
478
|
+
connection, and at 64 threads the pool ran out (5 s checkout timeouts, about 0.1% of ES-NoProj's
|
|
479
|
+
operations in the benchmark's 64-thread cells). `Lyra::OwnConnection` now runs such work: on a
|
|
480
|
+
connection of its own when one can be had, otherwise inline in the caller's transaction, with
|
|
481
|
+
the model recorded as linked or imported only once that transaction commits (after a rollback
|
|
482
|
+
the work runs again on the next use). The catch-up runs once per model and process; concurrent
|
|
483
|
+
first lookups wait for it without a second connection. `test/projections/reference_links_test.rb`:
|
|
484
|
+
a pinned connection, and concurrent first lookups inside transactions.
|
|
485
|
+
- **`has_and_belongs_to_many` links were not recorded.** The join table has no model of its own
|
|
486
|
+
(Active Record writes it through an anonymous class), so `item.tags << tag` wrote the join row
|
|
487
|
+
and no event recorded it: a rebuild lost the association and DualView did not compare it. Every
|
|
488
|
+
insert into and delete from a HABTM join table is now an `AssociationLinked` or
|
|
489
|
+
`AssociationUnlinked` event on the owner's stream, linked into each monitored associated
|
|
490
|
+
record's stream (`Lyra::Associations::HabtmLinks`), in every mode. DualView compares each HABTM
|
|
491
|
+
association's links with the join table, `Rebuild` restores join tables from the events, and
|
|
492
|
+
Genesis records the links pre-existing rows had. Under ES-NoProj, HABTM writes failed before
|
|
493
|
+
reaching Lyra (`CachedRelation` lacked `where_values_hash`); they now work and read back
|
|
494
|
+
(`test/habtm_links_test.rb`, every mode; mpantel/lyra#21).
|
|
495
|
+
- **Optimistic locking: a stale update silently won under event sourcing.** Active Record checks
|
|
496
|
+
`lock_version` in the UPDATE or DELETE that writes the row; ES-Sync, ES-Async, ES-NoProj and
|
|
497
|
+
ES-Lazy skip that statement, so a stale `update` or `destroy` overwrote the newer write (a lost
|
|
498
|
+
update), and `touch` raised `StaleObjectError` on every call under ES-NoProj, where no row
|
|
499
|
+
matches. No mode's events recorded the version's increment, so the log replayed to a
|
|
500
|
+
`lock_version` the row did not have (DualView: `lock_version: { crud: 1, event_sourced: 0 }`).
|
|
501
|
+
Every write's event now records the increment, `touch` included; the event-sourcing modes check
|
|
502
|
+
the version against the log before appending, under the lock on the aggregate (taken for such a
|
|
503
|
+
model in every event-sourcing mode), and raise `ActiveRecord::StaleObjectError` as Active Record
|
|
504
|
+
does (`Lyra::OptimisticLocking`). A record that expects a newer version than the log's passes,
|
|
505
|
+
so streams written before the change keep working (`test/optimistic_locking_test.rb`, every
|
|
506
|
+
mode, concurrent writers included; mpantel/lyra#20).
|
|
507
|
+
- **Encrypted attributes (Active Record `encrypts`) reached the event log in clear** while their
|
|
508
|
+
row held ciphertext: events are built from attributes Active Record has already decrypted.
|
|
509
|
+
`Lyra.append_events`, through which every event passes, now stores those attributes' values (in
|
|
510
|
+
`attributes` and `changes`) as the ciphertext Active Record writes to the column
|
|
511
|
+
(`Lyra::EncryptedAttributes`); projections, rebuilds and DualView decrypt them, and records
|
|
512
|
+
instantiated from events are decrypted by Active Record. Events written before keep their clear
|
|
513
|
+
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).
|
|
515
|
+
- **ES-Lazy stopped projecting after the event store was emptied or restored without its
|
|
516
|
+
checkpoint table**: the checkpoint then lay past the newest event, and every new event looked
|
|
517
|
+
applied. A checkpoint past the newest event is now recognised, warned about and reset (applying
|
|
518
|
+
is idempotent). It made two tests fail in some orders, since the test suite reloads the event
|
|
519
|
+
tables but not the checkpoint table (`test/projections/lazy_projection_test.rb`).
|
|
520
|
+
- **Single-table inheritance: a record's events were filed under its concrete class's stream**
|
|
521
|
+
(`Spree::Calculator::Shipping::FlatRate$1`), and anything given the base class read
|
|
522
|
+
`Spree::Calculator$1` and found none: DualView reported the row as missing from the log, and
|
|
523
|
+
under ES-NoProj, ES-Lazy or a rebuild the base class saw an empty stream. A record's stream is
|
|
524
|
+
now named after the class that owns the table (`Lyra.stream_name`, `Lyra.stream_prefix`), at
|
|
525
|
+
every one of the 21 places that built it; reference links and Lyra's caches follow the table.
|
|
526
|
+
Under ES-NoProj a subclass reads only its own records and its subclasses', as ActiveRecord's
|
|
527
|
+
type condition does; events naming a subclass of a monitored model resolve to it
|
|
528
|
+
(`Configuration#monitored_model_named`, used by ES-Lazy's catch-up and erasure). Event types
|
|
529
|
+
still name the concrete class. **Upgrade**: `bin/rails lyra:streams:migrate_sti`
|
|
530
|
+
(`Lyra::StiStreamMigration`) links events written before into the base streams, once; it
|
|
531
|
+
reports a base stream that already holds newer events instead of linking out of order
|
|
532
|
+
(`test/sti_streams_test.rb`, every mode).
|
|
533
|
+
- **ES-Async projected inline could leave the row behind the log.** With
|
|
534
|
+
`async_projections_inline` (the test environment's default) a write applied only its own event,
|
|
535
|
+
inside its transaction and under no lock: of two writers of one record, the one that projected
|
|
536
|
+
last could leave the row at its older value. Inline now runs what `AsyncProjectionJob` runs, in
|
|
537
|
+
the writer's thread once its transaction has committed, under the per-stream lock. The job path
|
|
538
|
+
was not affected (`test/concurrent_same_record_test.rb`).
|
|
539
|
+
- **ES-NoProj: a named scope called on an association was ignored** (`order.payments.completed`
|
|
540
|
+
returned every payment) and its body loaded every record of the model; records an association
|
|
541
|
+
loaded did not point back at their owner instance.
|
|
542
|
+
- **Documentation: `ReadYourWrites` projects a block's writes when the block ends**, so a read
|
|
543
|
+
after the block sees them and a read inside it does not; `docs/API_REFERENCE.md` said or
|
|
544
|
+
implied the latter.
|
|
545
|
+
- **ES-Lazy: concurrent first reads no longer fail creating the checkpoint table.** Two threads
|
|
546
|
+
that both found `lyra_projection_checkpoints` missing both created it, and the second `CREATE`
|
|
547
|
+
failed the write that triggered it (one write of the full BPI 2017 replay at two threads).
|
|
548
|
+
`Lyra.create_own_table` now defines the table in one transaction and ignores a failed create
|
|
549
|
+
once the table exists.
|
|
550
|
+
- **Leaving ES-Lazy skipped its catch-up** — `ModeTransition.to!` from `event_sourcing/lazy`
|
|
551
|
+
calls `LazyProjection.catch_up!(force: true)`. Without `force:`, `catch_up!` returns 0 outside
|
|
552
|
+
ES-Lazy, and the mode had already changed by the time the transition ran it, so the tables
|
|
553
|
+
could be left behind the log.
|
|
554
|
+
- **Locks on databases other than PostgreSQL** — Genesis, ES-Lazy catch-up and ES-Async
|
|
555
|
+
projection take their transaction-level advisory lock through `Lyra::AdvisoryLock.xact_lock`.
|
|
556
|
+
On SQLite (which serialises writers) it does nothing; on other adapters it logs once per
|
|
557
|
+
purpose that the step runs unlocked and is safe in a single process only. Before, those
|
|
558
|
+
adapters ran unlocked silently.
|
|
559
|
+
- **`upsert_all` under ES-NoProj raised `ArgumentError`**; it now raises
|
|
560
|
+
`Lyra::Projections::UnsupportedQuery`, like every other query ES-NoProj cannot answer, naming
|
|
561
|
+
the reason (the `ON CONFLICT` check runs against the table, not the event stream) and the
|
|
562
|
+
per-record alternative.
|
|
563
|
+
- **`DualView` reported a record for a stream of only `replay: false` events** — such a stream
|
|
564
|
+
describes no record, so `exists` is now false.
|
|
565
|
+
- **`lyra.rb` required ActiveRecord and ActiveJob implicitly**, relying on the host to load them
|
|
566
|
+
first; loading the gem after only `require "rails"` raised `NameError`. It now requires both.
|
|
567
|
+
- **`Lyra::Projection.subscribe_to` raised `RubyEventStore::InvalidHandler`** — RailsEventStore
|
|
568
|
+
3.1 subscribers must respond to `call`. A projection class now does (`call(event)` hands the
|
|
569
|
+
event to a new instance's `handle`), and `subscribe_to` returns the unsubscribe procs.
|
|
570
|
+
`handle` takes the `apply_<name>` method from `event.event_type`, so an event read back as a
|
|
571
|
+
plain `RubyEventStore::Event` reaches its handler too.
|
|
572
|
+
- **Nested `with_guaranteed_read` lost the outer block's writes** — the inner block reset the
|
|
573
|
+
list of pending writes, then cleared it. An inner block now projects its own writes when it
|
|
574
|
+
ends and restores the outer list; if it raises, its unprojected writes pass to the outer
|
|
575
|
+
block. Every write is projected by the time the outermost block ends.
|
|
576
|
+
- **A process that only reads got plain `RubyEventStore::Event`s** — in a lazily loaded process
|
|
577
|
+
(console, rake, development) no event class was registered, so events came back without
|
|
578
|
+
Lyra's readers (`operation`, `attributes`, `changes`, ...). `Lyra::Events` now defines the
|
|
579
|
+
class on first reference for a name a monitored model or the stored schema writes, loading
|
|
580
|
+
the model from the name's stem (`PostCreated` → `Post`, `SpreeOrderCreated` →
|
|
581
|
+
`Spree::Order`); other names still raise `NameError`. A model with a custom `event_prefix` or
|
|
582
|
+
`event_mapping` that is not loaded resolves only through the schema store.
|
|
583
|
+
- **petri_flow was not optional** — `app/workflows/*.rb` subclass `PetriFlow::Workflow`, so
|
|
584
|
+
without the gem eager loading (production boot, `lyra:mode:*`, `lyra:repair`,
|
|
585
|
+
`lyra:schema:*`) raised `NameError`. Without petri_flow the engine neither autoloads nor
|
|
586
|
+
eager-loads `app/workflows`.
|
|
587
|
+
- **`lyra:workflows:generate MODE=...` wrote a class its file did not name** — `MODE=monitor`
|
|
588
|
+
defined `MonitorModeWorkflowWorkflow`, which Zeitwerk could not load. A mode's file and class
|
|
589
|
+
are now the same whether one mode or all are generated (`monitor_mode_workflow.rb` defines
|
|
590
|
+
`MonitorModeWorkflow`, `es_sync_mode_workflow.rb` `EsSyncModeWorkflow`;
|
|
591
|
+
`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`
|
|
594
|
+
uses `gemspec glob: "lyra.gemspec"`, and the example apps' Gemfiles `glob: "lyra.gemspec"`.
|
|
595
|
+
- **Hijack and event-sourcing events lost who wrote them** — the command handler wrote only
|
|
596
|
+
the causal chain, so `user_id`, `request_id`, the user action and `config.metadata_proc`
|
|
597
|
+
reached Monitor events alone. Every mode now carries the same attribution metadata
|
|
598
|
+
(`record.lyra_event_metadata(operation)`), plus `source: "lyra_command_handler"`.
|
|
599
|
+
- **`ReadYourWrites` had no effect under ES-Async** — only the ES-Sync path recorded writes
|
|
600
|
+
for it. Writes inside `with_guaranteed_read` are now projected when the block ends; the job
|
|
601
|
+
is still enqueued and converges on the same row.
|
|
602
|
+
- **Aggregates never loaded their history** — `GenericAggregate.load` raised `NoMethodError`
|
|
603
|
+
(no model class for its stream name) and the command handler swallowed it. A model's own
|
|
604
|
+
`aggregate_class` is now loaded with its stream's history for updates and destroys; the
|
|
605
|
+
default `GenericAggregate`, which decides nothing from history, starts empty and reads no
|
|
606
|
+
stream, so its writes issue no extra SQL. A programming error while loading now fails the
|
|
607
|
+
command instead of being swallowed.
|
|
608
|
+
- **`AuditProjection.audit_trail` always gave `user_id: nil`** — it read the user from the
|
|
609
|
+
event data, which no longer holds the metadata; it reads the event's metadata now.
|
|
610
|
+
- **DualView reported every destroyed record as `exists_mismatch`** — a destroyed record
|
|
611
|
+
whose row is gone now compares clean (`event_sourced_state` gains `destroyed:`).
|
|
612
|
+
- **The engine's root redirect was hard-coded to `/lyra/dashboard`** — it is now relative
|
|
613
|
+
to wherever the engine is mounted.
|
|
614
|
+
- **`strict_schema` stopped the boot on any schema drift** — including info-level changes
|
|
615
|
+
such as an added column. It now refuses to boot only on a breaking change (model or column
|
|
616
|
+
removed, column type or event name changed) and logs the rest.
|
|
617
|
+
- **`PIIMasker.mask_events` was broken** — it built events with keywords
|
|
618
|
+
`RubyEventStore::Event` does not accept. It returns masked copies now (same class, id and
|
|
619
|
+
metadata; attributes and changes masked).
|
|
620
|
+
- **`Erasure::Result#shared_values` was sometimes `[]`, sometimes an Integer** — always an
|
|
621
|
+
Integer count now.
|
|
622
|
+
- **`EventFlow#crud_to_event_mapping` found nothing for a class** — it accepts a class or its
|
|
623
|
+
name, and string operations and ids (as the dashboard passes them); `data_lineage` likewise.
|
|
624
|
+
- **A test left a guard on `Article#body=`** (`multi_mode_integration_test`), failing 11 later
|
|
625
|
+
tests under some seeds; it now removes it.
|
|
626
|
+
- **ES-NoProj answers more queries exactly instead of refusing them** — scopes with `order` or
|
|
627
|
+
`limit` (their conditions, then the order and limit); `relation.or(other)` over the same model
|
|
628
|
+
(the union); and `where` fragments of `col OP ?` terms joined all by AND or all by OR, with OP
|
|
629
|
+
one of `= != <> < <= > >= LIKE ILIKE` (values cast to the column type, NULL never matching, LIKE
|
|
630
|
+
patterns with `%`, `_` and `\` escapes). Anything else is still refused. `order("col DESC")`
|
|
631
|
+
strings and Arel orderings are read, and an ordering it cannot read now raises: it used to
|
|
632
|
+
compare as equal and leave the result silently unordered. The testbed suite under ES-NoProj
|
|
633
|
+
goes from 20 failures to none (the domain-events tests also created their records before
|
|
634
|
+
switching mode, so under ES-NoProj the parents existed only as events).
|
|
635
|
+
- **ES-NoProj lost `update_columns` and `touch`** — in the events-only store the `UPDATE` matched no
|
|
636
|
+
row and returned false, so no event was published and the change was lost, against Bypass
|
|
637
|
+
Coverage ("the events are the write"). The event is now published whatever the row count.
|
|
638
|
+
- **ES-Lazy lost a callback-bypassing write made right after a write** — a write appends events
|
|
639
|
+
only, so the record's row did not exist until the next read; `update_columns`, `update_all`
|
|
640
|
+
or `delete_all` on it matched nothing, recorded no event, and the change was lost.
|
|
641
|
+
`BypassEvents.atomically` now brings the tables up to date first, as a read does.
|
|
642
|
+
- **Lyra's own tables vanished with a rolled-back transaction** — `lyra_projection_checkpoints`
|
|
643
|
+
and `lyra_mode_transitions` were created inside whatever transaction was open, and remembered
|
|
644
|
+
as created; when it rolled back (a failed request, every transactional test) the table was
|
|
645
|
+
gone and every later use failed. Under ES-Lazy the testbed suite failed 395 tests this way.
|
|
646
|
+
They are now created on a connection of their own when a transaction is open
|
|
647
|
+
(`Lyra.create_own_table`).
|
|
648
|
+
- **ES-NoProj ignored `select` and hid purpose refusals as "not found"** — `CachedRelation#select`
|
|
649
|
+
returned whole records, and the record builder swallowed a policy error, dropping the record.
|
|
650
|
+
Query results are now narrowed to the selected columns and checked when returned
|
|
651
|
+
(`CachedRelationDelivery`), as are the class-level `find`, `find_by` and `find_by!`.
|
|
652
|
+
- **The all-modes runner skipped ES-Lazy** — `lyra:test:all_modes` and the PAM-less run now cover
|
|
653
|
+
all seven configurations.
|
|
654
|
+
- **Lyra's rake tasks ran twice, and host apps got the monorepo's maintenance tasks** — Rails
|
|
655
|
+
loads every `lib/tasks/*.rake` under an engine's root, and the engine also loaded its three
|
|
656
|
+
task files explicitly, so each `lyra:mode:*`, `lyra:projections:*` and `lyra:schema:*` task
|
|
657
|
+
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,
|
|
659
|
+
once.
|
|
660
|
+
- **`lyra:mode:check` could certify a switch having checked nothing** — monitored models register
|
|
661
|
+
as their classes load, which a rake task in development does not do, so the check saw no
|
|
662
|
+
models, found no discrepancy and certified the switch. The mode, repair, projection and schema
|
|
663
|
+
tasks now load the application's models first; check and repair refuse when none are monitored.
|
|
664
|
+
- **Renamed events were skipped on replay** — `Rebuild`, the ES-NoProj cache and the aggregate
|
|
665
|
+
decided what an event did from the end of its name (`…Created`, `…Updated`, `…Destroyed`).
|
|
666
|
+
An event renamed with `event_mapping` (or, now, a domain event) was skipped: `Rebuild` left
|
|
667
|
+
the record's row empty and ES-NoProj could not read it. Every replay site now dispatches on
|
|
668
|
+
the operation each event records in its data (`Lyra::Event.operation_of`); only events too
|
|
669
|
+
old to carry one fall back to the name.
|
|
670
|
+
- **Event sourcing lost writes whose event could not be stored** — `lyra_store_events` logged a
|
|
671
|
+
failed append and carried on (unless `strict_projections`, a setting about projections, was
|
|
672
|
+
on): ES-NoProj reported a write that existed nowhere, and ES-Sync projected a row from an
|
|
673
|
+
event that was never stored. In Hijack and the event-sourcing modes a write whose event
|
|
674
|
+
cannot be stored now fails and is rolled back, bypass writes included
|
|
675
|
+
(`BypassEvents.required?`, `BypassEvents.atomically`: the write and its events share a
|
|
676
|
+
transaction). Monitor, where the table stays authoritative, still logs the failure and keeps
|
|
677
|
+
the write.
|
|
678
|
+
- **A failed event-sourced create made the next insert on its thread vanish** — the
|
|
679
|
+
skip-insert signal was cleared only at the end of a successful finalize, so after any
|
|
680
|
+
failure the next insert of any model on that thread (a Puma thread, say) skipped its row while
|
|
681
|
+
reporting success. It is now cleared as soon as the record's own INSERT has run, and
|
|
682
|
+
finalize clears its state on every exit.
|
|
683
|
+
- **ES-NoProj evaluated association conditions record by record, and some wrongly** —
|
|
684
|
+
`where(program: x)` loaded each cached record's association (one query per record: about
|
|
685
|
+
10,000 for one `count` in the mode-comparison benchmark) and compared objects;
|
|
686
|
+
`find_by(program: x)` compared against a `program` attribute the record does not have and
|
|
687
|
+
silently found nothing; `where(program_id: record)` matched nothing. Conditions are now
|
|
688
|
+
rewritten as ActiveRecord does (`Lyra::Projections::AssociationConditions`): a `belongs_to`
|
|
689
|
+
becomes its foreign key (polymorphic: type and id), and a record given as a column value
|
|
690
|
+
becomes its id. A `has_many`/`has_one` condition, which ActiveRecord accepts only with a
|
|
691
|
+
join, raises `UnsupportedQuery`, as does a polymorphic condition over several types.
|
|
692
|
+
- **ES-NoProj could serve out-of-date records from its cache** — a write warmed its record's
|
|
693
|
+
entry inside the write's transaction, so a rollback left a record cached that never existed;
|
|
694
|
+
two processes filling one key could leave a stale entry for up to an hour; and
|
|
695
|
+
`where`/`find_by` results were cached for five minutes and never invalidated, so they could
|
|
696
|
+
return records a write had already changed. Stamped entries (see Changed) make all three
|
|
697
|
+
impossible: a stale entry is never used.
|
|
698
|
+
- **An explicit id was replaced** — `CommandHandler#handle_create` generated an id even when the
|
|
699
|
+
application set one; it now keeps it, in the event and in the row. From `thesis-restructure`
|
|
700
|
+
(526908e), applied to master's own Hijack fix (cd3ea89).
|
|
701
|
+
- **ES-NoProj `insert_all` ran, then raised** — through `CachedRelation`'s scope branch the
|
|
702
|
+
insert executed and the caller then got `UnsupportedQuery` ("not a scope"). Inserts and
|
|
703
|
+
upserts now go to the table relation and return its result.
|
|
704
|
+
- **Genesis left a cached "not found" in place on Solid Cache** — an ES-NoProj lookup
|
|
705
|
+
made before a record was imported (in an earlier process, or before the event store
|
|
706
|
+
was reset) caches `nil` for it. Genesis cleared the cache with
|
|
707
|
+
`CachedProjection.invalidate_all`, which cannot delete per-record entries on a store
|
|
708
|
+
without prefix deletion (Solid Cache, the Rails 8 default), so the imported record stayed
|
|
709
|
+
"not found" until the entry expired, up to an hour. Genesis now drops each imported
|
|
710
|
+
record's entry by id. Found by the Aegean benchmark harness, which empties the event
|
|
711
|
+
store between modes.
|
|
712
|
+
- **`CachedRelation#delete_all`/`destroy_all`/`update_all` wiped the whole table**
|
|
713
|
+
(`Lyra::Projections::CachedRelation`, from `stack/latest-ruby-rails-res`) — the
|
|
714
|
+
`method_missing` scope fallback rebuilt them from `model_class.unscoped` and ran them
|
|
715
|
+
before checking the result, so `Model.where(id: ids).delete_all` under ES-NoProj issued
|
|
716
|
+
a bare `DELETE FROM` the whole table (found when the Aegean benchmark's ES-NoProj
|
|
717
|
+
cleanup emptied `registrations` mid-sweep). They now act only on the relation's own
|
|
718
|
+
records. `where(column: [a, b])` (an `IN` with several values) is no longer silently
|
|
719
|
+
dropped.
|
|
720
|
+
- **Projection wrote through the read-from-events override** (`Lyra::Projections::ModelProjection`)
|
|
721
|
+
— with `projection_mode :disabled` (ES-NoProj) a monitored model's `where`/`all` answer
|
|
722
|
+
from the event store. Projection's update and destroy go through
|
|
723
|
+
`model_class.where(...)`, so they acted on event-store records instead of rows: a
|
|
724
|
+
record destroyed in the log is absent there, so its row was never deleted.
|
|
725
|
+
`Rebuild` (`rake lyra:projections:rebuild`) and `AsyncProjectionJob` both project
|
|
726
|
+
through this path in any mode. Projection now always addresses the table.
|
|
727
|
+
- **`invalidate_all` raised on Solid Cache, and keyed `find_by` replayed every stream**
|
|
728
|
+
(`Lyra::Projections::CachedProjection`) — `invalidate_all` guarded prefix deletion with
|
|
729
|
+
`respond_to?(:delete_matched)`, which every cache store answers true; Solid Cache, the
|
|
730
|
+
Rails 8 default, then raised `NotImplementedError`. The raise is now rescued and the
|
|
731
|
+
fallback used. `find_by` took its single-stream path only when the primary key was the
|
|
732
|
+
sole condition, so `find_by(id: 5, email: x)` replayed every stream of the model; any
|
|
733
|
+
lookup that pins the primary key now reads that one stream and checks the remaining
|
|
734
|
+
conditions against the record it yields.
|
|
735
|
+
- **ES-Async projected a record's events out of order** (`Lyra::Projections::AsyncProjectionJob`)
|
|
736
|
+
— each job applied its one event, and a worker pool runs jobs concurrently, so an
|
|
737
|
+
earlier update's job could land after a later one's and roll the row back; a late
|
|
738
|
+
create job could even resurrect a destroyed record. The Olist replay through Solidus
|
|
739
|
+
left 2 of 100 orders at `confirm` whose events ended at `complete`. Each job now
|
|
740
|
+
brings its record up to date with the whole stream (`Rebuild.replay_record`, in place),
|
|
741
|
+
under a PostgreSQL advisory lock held per stream, so jobs converge in any order. Cost:
|
|
742
|
+
each job re-reads its stream, so projections lag more and ES-Async's read-after-write
|
|
743
|
+
window widens (Olist, 100 orders: 151 -> 183 such failures).
|
|
744
|
+
- **Hijack and event-sourcing modes built events before the model's own callbacks**
|
|
745
|
+
(`Lyra::Interceptors::CrudInterceptor`, new `WriteHooks`) — the create/update/destroy
|
|
746
|
+
commands ran in `before_*` callbacks registered on `ActiveRecord::Base`, so ahead of
|
|
747
|
+
every model's own `before_*` callbacks. Values those set were missing from the event:
|
|
748
|
+
in Hijack mode the event disagreed with the row; under event sourcing, where the row is
|
|
749
|
+
projected from the event, they were lost from the table, and DualView could not tell
|
|
750
|
+
(both sides agreed). Replaying Olist orders through Solidus 4.7 under ES-Sync lost every
|
|
751
|
+
order's `guest_token` and every payment's `number`. The commands now run in
|
|
752
|
+
`WriteHooks`, prepended to `ActiveRecord::Persistence`: after all `before_*` callbacks,
|
|
753
|
+
just before the SQL write. A failed command returns false (save returns false, the
|
|
754
|
+
transaction rolls back) instead of throwing `:abort`. PaperTrail is still switched off
|
|
755
|
+
first. SQL statements per write are unchanged in every mode, in order and count.
|
|
756
|
+
- **Hijack creates re-assigned every attribute** (`Lyra::Interceptors::CrudInterceptor`) —
|
|
757
|
+
after the create command, the interceptor called `assign_attributes` with all of the
|
|
758
|
+
record's attributes, although only the reserved ID had changed. That fails on models
|
|
759
|
+
that guard a writer: Solidus makes `StockItem#count_on_hand=` unusable so stock moves
|
|
760
|
+
only through `set_count_on_hand`, and Hijack mode could not create a variant. Only the
|
|
761
|
+
ID is assigned now.
|
|
762
|
+
- **Writes that bypass callbacks went missing from the event log** (`Lyra::StrictDataAccess`)
|
|
763
|
+
— three holes, found by the Olist replay through Solidus 4.7, where DualView then
|
|
764
|
+
disagreed with the table for 17 of 20 orders and every shipment:
|
|
765
|
+
- *Assign, then `update_columns`* published nothing. The old value was read from the
|
|
766
|
+
in-memory attribute, which already held the new value, so no change was seen
|
|
767
|
+
(Solidus's `Shipment#persist_amounts`). The old value now comes from
|
|
768
|
+
`attribute_in_database` and the new one from the record after the write, which also
|
|
769
|
+
covers the timestamps `touch: true` sets.
|
|
770
|
+
- *`touch`* was not intercepted at all (Solidus records completion with
|
|
771
|
+
`touch(:completed_at)`). It now publishes a bypass event; it is not a strict-mode
|
|
772
|
+
violation, since Rails itself calls it for `belongs_to ... touch: true`.
|
|
773
|
+
- *`update_column`* published its event twice (Lyra's override and Rails'
|
|
774
|
+
`update_columns` underneath both published), and the override dropped Rails 8's
|
|
775
|
+
`touch:` keyword, so callers passing it raised `ArgumentError`.
|
|
776
|
+
- **Hijack and event-sourcing modes failed on namespaced models** (`Lyra::CommandHandler`) —
|
|
777
|
+
the event class name was built from the model name without stripping `::`, so
|
|
778
|
+
`Spree::Price` produced the invalid constant `Spree::PriceCreated` and every create
|
|
779
|
+
failed. The failure surfaced only as a parent record left unsaved, with no exception
|
|
780
|
+
(`Spree::Product.create!` returned an unsaved product). Every other event path already
|
|
781
|
+
stripped the separator; this one now does too. Found by the Olist replay through
|
|
782
|
+
Solidus 4.7, where it, not STI or polymorphism, kept Solidus out of these modes.
|
|
783
|
+
- **Optional gems that failed to load were reported as missing** (`lyra.rb`,
|
|
784
|
+
`Lyra::OptionalDependency`) — PAM DSL and PetriFlow were loaded inside a bare
|
|
785
|
+
`rescue LoadError`, so a gem that was installed but could not load (PetriFlow without
|
|
786
|
+
`rexml`) was treated as absent and left half-defined; eager-loading `app/workflows`
|
|
787
|
+
then failed with `uninitialized constant PetriFlow::Workflow`. Only a gem that is itself
|
|
788
|
+
not installed now counts as unavailable; any other load failure raises. PetriFlow now
|
|
789
|
+
declares `rexml` (see `gems/petri_flow/CHANGELOG.md`).
|
|
790
|
+
- **DualView compared times at whole seconds** (`Lyra::DualView`) — `normalize_value` turned
|
|
791
|
+
every time into `iso8601` with no fraction, so `15:49:11.420` in the table and
|
|
792
|
+
`15:49:11 UTC` in a lossy event compared equal. That is how the sub-second loss fixed by
|
|
793
|
+
`Lyra::EventSerializer` passed dual-view checks under Monitor mode. Times now compare at
|
|
794
|
+
microseconds, the resolution of a PostgreSQL timestamp.
|
|
795
|
+
- **Hijack mode filed `Created` under a placeholder stream** (`Lyra::CommandHandler`) — for
|
|
796
|
+
integer primary keys, hijack mode stored the Created event under `"pending-<hex>"` and let
|
|
797
|
+
the database assign the ID afterwards. Nothing linked the two: later events went to the
|
|
798
|
+
real `Model$<id>` stream, so every record's history began with an update and rebuilding it
|
|
799
|
+
from events (dual view, projection rebuild) found no attributes. Hijack now reserves the
|
|
800
|
+
ID up front through `IdGenerator`, as event-sourcing mode does, and the row is inserted
|
|
801
|
+
under the ID the event carries. Hijack is now covered by the event-correctness
|
|
802
|
+
integration tests it had been left out of.
|
|
803
|
+
- **ES-Async lost projections silently** (`Lyra::Projections::AsyncProjectionJob`) — the job
|
|
804
|
+
was enqueued inside the transaction that stores its event, so a worker could run it
|
|
805
|
+
before the commit, find no event, complete, and never project the row (no error, no
|
|
806
|
+
retry). Replaying 40 BPI 2017 applications left 37 missing. The job now sets
|
|
807
|
+
`enqueue_after_transaction_commit`, so it runs only once its event is visible and is
|
|
808
|
+
never enqueued after a rollback. Eventual consistency itself is unchanged. The test dummy
|
|
809
|
+
app now loads `active_job/railtie`.
|
|
810
|
+
|
|
10
811
|
## [0.8.0] - 2026-08-09
|
|
11
812
|
|
|
12
|
-
The version the PAM article (Requirements Engineering, 2026) describes
|
|
813
|
+
The version the PAM article (Requirements Engineering, 2026) describes, released from
|
|
814
|
+
lyra-engine commit `b863d2e`. Not published to RubyGems until 2026-10-10.
|
|
13
815
|
|
|
14
816
|
### Added
|
|
15
817
|
- **Projection rebuild from the event log** (`Lyra::Projections::Rebuild`) - Reconstruct
|