funes-rails 0.2.2 → 0.2.3
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/README.md +13 -22
- data/app/projections/funes/projection.rb +49 -10
- data/lib/funes/invalid_materialization_state.rb +31 -0
- data/lib/funes/version.rb +1 -1
- data/lib/funes.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa21a99408ee10d9f9b17e06af87f2f51d099fd593fadec8672c71b15110a53e
|
|
4
|
+
data.tar.gz: 893a107d52489ffc4e7680f558dbe8dfce0c8bdf88ad05bd4a704efd0eeb97ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eddd2d0d5c46fe75338a095098a5391eb3aa81b29196f4fd352458ba6e6efc75673959ac59b1b40c555ec1a2f8914524503f718017845f636b030577578dc3f6
|
|
7
|
+
data.tar.gz: c57f6bb79093b02c19abc8c5246bb741c4cf23d16c24dc2e1857e7941457e02ddd4a2e83441b767302bf6c4322bb88a087703bf2480031ac0bc7aa5e86470607
|
data/README.md
CHANGED
|
@@ -90,25 +90,6 @@ Funes gives you fine-grained control over when and how projections run:
|
|
|
90
90
|
* **Validation before persistence:** before upserting the materialization, Funes runs ActiveRecord validations on the materialization model. If the model is invalid, an `ActiveRecord::RecordInvalid` exception is raised, the transaction rolls back, and the event is not persisted.
|
|
91
91
|
* **Fail-loud on errors:** if a projection fails with a database error (e.g., constraint violation) or a validation error, the transaction rolls back, the event is marked as not persisted (`persisted?` returns `false`), and the exception (`ActiveRecord::StatementInvalid` or `ActiveRecord::RecordInvalid`) propagates. This ensures bugs are immediately visible rather than silently hidden, while keeping the event in a consistent state for any rescue logic in your application.
|
|
92
92
|
|
|
93
|
-
### Host-managed transactions with `append!`
|
|
94
|
-
|
|
95
|
-
When you need to wrap `append` inside your own `ActiveRecord::Base.transaction` — for example, to keep a sibling update in lockstep with the event, or to append to two streams atomically — use `append!`. It mirrors Rails' `save` / `save!` pair: on any failure it raises `ActiveRecord::RecordInvalid`, which rolls back the enclosing transaction.
|
|
96
|
-
|
|
97
|
-
```ruby
|
|
98
|
-
event = Order::Placed.new(total: 99.99)
|
|
99
|
-
begin
|
|
100
|
-
ActiveRecord::Base.transaction do
|
|
101
|
-
customer.update!(last_ordered_at: Time.current)
|
|
102
|
-
OrderEventStream.for(order_id).append!(event)
|
|
103
|
-
end
|
|
104
|
-
rescue ActiveRecord::RecordInvalid
|
|
105
|
-
event.persisted? # => false
|
|
106
|
-
event.errors.any? # => true
|
|
107
|
-
end
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
The failed event stays queryable after the rescue (`persisted?`, `errors`), just like a record that failed `save!`. Async projections are only enqueued once the outer transaction commits, so a rolled-back transaction never schedules a job.
|
|
111
|
-
|
|
112
93
|
### Async projections
|
|
113
94
|
|
|
114
95
|
* **Background processing:** these are offloaded to `ActiveJob`, ensuring that heavy computations don't slow down the write path.
|
|
@@ -122,12 +103,22 @@ The failed event stays queryable after the rescue (`persisted?`, `errors`), just
|
|
|
122
103
|
|
|
123
104
|
Guides and full API documentation are available at [docs.funes.org](https://docs.funes.org).
|
|
124
105
|
|
|
106
|
+
## Performance
|
|
107
|
+
|
|
108
|
+
Precise benchmarks are notoriously hard to pin down: workloads vary, and absolute numbers depend heavily on hardware, configuration, and the shape of the data. So treat the figures we publish as directional rather than definitive.
|
|
109
|
+
|
|
110
|
+
That said, our measurements consistently show that the complexity of event stream operations stays **sub-linear, comfortably within `O(n)`** as the log grows — which is an important property for medium/long-lived streams.
|
|
111
|
+
|
|
112
|
+
You can browse the latest measurements and join the conversation in the [Performance Measurements](https://github.com/funes-org/funes/discussions/categories/performance-measurements) discussions.
|
|
113
|
+
|
|
125
114
|
## Compatibility
|
|
126
115
|
|
|
127
|
-
|
|
128
|
-
|
|
116
|
+
Funes supports the following runtimes:
|
|
117
|
+
|
|
118
|
+
- **Ruby** 3.1 or newer
|
|
119
|
+
- **Rails** 7.2 or newer
|
|
129
120
|
|
|
130
|
-
Rails 8.0
|
|
121
|
+
If you're on Rails 8.0 or above, you'll need Ruby 3.2 or newer — that's a Rails 8 requirement, not a Funes one.
|
|
131
122
|
|
|
132
123
|
## License
|
|
133
124
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "funes/unknown_event"
|
|
2
2
|
require "funes/unknown_materialization_model"
|
|
3
|
+
require "funes/invalid_materialization_state"
|
|
3
4
|
|
|
4
5
|
module Funes
|
|
5
6
|
# Projections perform the necessary pattern-matching to compute and aggregate the interpretations that the system
|
|
@@ -122,6 +123,31 @@ module Funes
|
|
|
122
123
|
@materialization_model = active_record_or_model
|
|
123
124
|
end
|
|
124
125
|
|
|
126
|
+
# Registers an instance method on the materialization model that owns the persistence step,
|
|
127
|
+
# replacing the framework's default upsert.
|
|
128
|
+
#
|
|
129
|
+
# When set, the named method is invoked on the in-memory state after all interpretations have
|
|
130
|
+
# run and +idx+ has been assigned. The method takes no arguments and is expected to raise on
|
|
131
|
+
# failure. The framework still calls +state.valid?+ and raises
|
|
132
|
+
# +Funes::InvalidMaterializationState+ before delegating, so the custom method only runs
|
|
133
|
+
# against valid state.
|
|
134
|
+
#
|
|
135
|
+
# Declaring +persist_materialization_model_with+ also lifts the requirement that the
|
|
136
|
+
# materialization model be an +ActiveRecord::Base+ subclass: a plain +ActiveModel+ class is
|
|
137
|
+
# enough as long as it exposes +assign_attributes+, +attributes+, +valid?+ and +errors+.
|
|
138
|
+
#
|
|
139
|
+
# @param [Symbol] method_name The instance method on the materialization model that performs the write.
|
|
140
|
+
# @return [void]
|
|
141
|
+
#
|
|
142
|
+
# @example Persisting a projection to a JSON file
|
|
143
|
+
# class YourProjection < Funes::Projection
|
|
144
|
+
# materialization_model YourMaterializationModel
|
|
145
|
+
# persist_materialization_model_with :save_to_object_storage!
|
|
146
|
+
# end
|
|
147
|
+
def persist_materialization_model_with(method_name)
|
|
148
|
+
@persist_method = method_name
|
|
149
|
+
end
|
|
150
|
+
|
|
125
151
|
# It changes the sensibility of the projection about events that it doesn't know how to interpret
|
|
126
152
|
#
|
|
127
153
|
# By default, a projection ignores events that it doesn't have interpretations for. This method informs the
|
|
@@ -142,7 +168,8 @@ module Funes
|
|
|
142
168
|
def process_events(events_collection, at: nil, consistency: false)
|
|
143
169
|
new(self.instance_variable_get(:@interpretations),
|
|
144
170
|
self.instance_variable_get(:@materialization_model),
|
|
145
|
-
self.instance_variable_get(:@throws_on_unknown_events)
|
|
171
|
+
self.instance_variable_get(:@throws_on_unknown_events),
|
|
172
|
+
self.instance_variable_get(:@persist_method))
|
|
146
173
|
.process_events(events_collection, at: at, consistency: consistency)
|
|
147
174
|
end
|
|
148
175
|
|
|
@@ -150,13 +177,14 @@ module Funes
|
|
|
150
177
|
def materialize!(events_collection, idx, at: nil)
|
|
151
178
|
new(self.instance_variable_get(:@interpretations),
|
|
152
179
|
self.instance_variable_get(:@materialization_model),
|
|
153
|
-
self.instance_variable_get(:@throws_on_unknown_events)
|
|
180
|
+
self.instance_variable_get(:@throws_on_unknown_events),
|
|
181
|
+
self.instance_variable_get(:@persist_method))
|
|
154
182
|
.materialize!(events_collection, idx, at: at)
|
|
155
183
|
end
|
|
156
184
|
end
|
|
157
185
|
|
|
158
186
|
# @!visibility private
|
|
159
|
-
def initialize(interpretations, materialization_model, throws_on_unknown_events)
|
|
187
|
+
def initialize(interpretations, materialization_model, throws_on_unknown_events, persist_method = nil)
|
|
160
188
|
@interpretations = interpretations
|
|
161
189
|
@materialization_model = materialization_model
|
|
162
190
|
raise Funes::UnknownMaterializationModel,
|
|
@@ -164,6 +192,7 @@ module Funes
|
|
|
164
192
|
|
|
165
193
|
|
|
166
194
|
@throws_on_unknown_events = throws_on_unknown_events
|
|
195
|
+
@persist_method = persist_method
|
|
167
196
|
end
|
|
168
197
|
|
|
169
198
|
# @!visibility private
|
|
@@ -191,13 +220,13 @@ module Funes
|
|
|
191
220
|
# @!visibility private
|
|
192
221
|
def materialize!(events_collection, idx, at: nil)
|
|
193
222
|
state = process_events(events_collection, at: at)
|
|
194
|
-
|
|
195
|
-
if materialization_model_is_persistable?
|
|
223
|
+
if persistable?
|
|
196
224
|
state.assign_attributes(idx:)
|
|
197
225
|
persist_based_on!(state)
|
|
226
|
+
return state if @persist_method
|
|
198
227
|
end
|
|
199
228
|
|
|
200
|
-
|
|
229
|
+
materialized_model_instance_based_on(state)
|
|
201
230
|
end
|
|
202
231
|
|
|
203
232
|
private
|
|
@@ -213,13 +242,23 @@ module Funes
|
|
|
213
242
|
@materialization_model.new(state.attributes)
|
|
214
243
|
end
|
|
215
244
|
|
|
216
|
-
def
|
|
217
|
-
@materialization_model.present?
|
|
245
|
+
def persistable?
|
|
246
|
+
return false unless @materialization_model.present?
|
|
247
|
+
return true if @persist_method
|
|
248
|
+
@materialization_model <= ActiveRecord::Base
|
|
218
249
|
end
|
|
219
250
|
|
|
220
251
|
def persist_based_on!(state)
|
|
221
|
-
|
|
222
|
-
|
|
252
|
+
unless state.valid?
|
|
253
|
+
raise Funes::InvalidMaterializationState.new(state) if @persist_method
|
|
254
|
+
raise ActiveRecord::RecordInvalid.new(state)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
if @persist_method
|
|
258
|
+
state.public_send(@persist_method)
|
|
259
|
+
else
|
|
260
|
+
@materialization_model.upsert(state.attributes, unique_by: :idx)
|
|
261
|
+
end
|
|
223
262
|
end
|
|
224
263
|
|
|
225
264
|
def warn_about_ineffective_errors(event)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
# Raised when a projection configured with `persist_materialization_model_with` produces an
|
|
3
|
+
# invalid materialization state.
|
|
4
|
+
#
|
|
5
|
+
# When a projection declares a custom persistence method via `persist_materialization_model_with`,
|
|
6
|
+
# Funes still validates the materialization (via `state.valid?`) before delegating the write.
|
|
7
|
+
# If validation fails, this exception is raised and the custom persist method is *not* invoked.
|
|
8
|
+
#
|
|
9
|
+
# The failed materialization is exposed via `#record`, mirroring the ergonomics of
|
|
10
|
+
# `ActiveRecord::RecordInvalid` — but without coupling the custom-persist path to ActiveRecord.
|
|
11
|
+
# Like any unrescued exception, it propagates out of `materialize!` and rolls back any enclosing
|
|
12
|
+
# `ActiveRecord::Base.transaction`, which is the desired behaviour for transactional projections.
|
|
13
|
+
#
|
|
14
|
+
# @example Handling an invalid materialization state
|
|
15
|
+
# begin
|
|
16
|
+
# YourProjection.materialize!(events, "some-id", at: Time.current)
|
|
17
|
+
# rescue Funes::InvalidMaterializationState => e
|
|
18
|
+
# Rails.logger.error "Invalid materialization: #{e.message}"
|
|
19
|
+
# e.record.errors.full_messages # the validation errors on the materialization
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @see Funes::Projection.persist_materialization_model_with
|
|
23
|
+
class InvalidMaterializationState < StandardError
|
|
24
|
+
attr_reader :record
|
|
25
|
+
|
|
26
|
+
def initialize(record)
|
|
27
|
+
@record = record
|
|
28
|
+
super("Invalid materialization state: #{record.errors.full_messages.to_sentence}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/funes/version.rb
CHANGED
data/lib/funes.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "funes/event_metainformation_builder"
|
|
|
5
5
|
require "funes/invalid_event_metainformation"
|
|
6
6
|
require "funes/conflicting_actual_time_error"
|
|
7
7
|
require "funes/missing_actual_time_attribute_error"
|
|
8
|
+
require "funes/invalid_materialization_state"
|
|
8
9
|
require "funes/engine"
|
|
9
10
|
|
|
10
11
|
# Funes is an event sourcing framework for Ruby on Rails.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: funes-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vinícius Almeida da Silva
|
|
@@ -216,6 +216,7 @@ files:
|
|
|
216
216
|
- lib/funes/event_metainformation_builder.rb
|
|
217
217
|
- lib/funes/inspection.rb
|
|
218
218
|
- lib/funes/invalid_event_metainformation.rb
|
|
219
|
+
- lib/funes/invalid_materialization_state.rb
|
|
219
220
|
- lib/funes/missing_actual_time_attribute_error.rb
|
|
220
221
|
- lib/funes/unknown_event.rb
|
|
221
222
|
- lib/funes/unknown_materialization_model.rb
|