funes-rails 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a51269f6bd4892236215ca7de5afd23bff48c49ca57a379cc7595cc4ef1ef0a8
4
- data.tar.gz: 57f27dbd3f84933ed3245e1c77892850f45ed3d2b05aaa7cdf74720f91428ef0
3
+ metadata.gz: fa21a99408ee10d9f9b17e06af87f2f51d099fd593fadec8672c71b15110a53e
4
+ data.tar.gz: 893a107d52489ffc4e7680f558dbe8dfce0c8bdf88ad05bd4a704efd0eeb97ce
5
5
  SHA512:
6
- metadata.gz: 11c9e49708f8631a8eae80fe1c1d5df8d647492e0fcad51ec859f7488cb5b96ed8c0ef1a180378470968b406645665cb908b383271c6dc816c41a6edd01834a6
7
- data.tar.gz: 5fa9fd8a1ee99d31bb930e9b5f6bf951a26681548d71e0e7f971f0c63c6b31f49dc4c183b0757ed5f9c12bfbd8a155d8599da6b86b20cf82b69e31bd1d1ff0bd
6
+ metadata.gz: eddd2d0d5c46fe75338a095098a5391eb3aa81b29196f4fd352458ba6e6efc75673959ac59b1b40c555ec1a2f8914524503f718017845f636b030577578dc3f6
7
+ data.tar.gz: c57f6bb79093b02c19abc8c5246bb741c4cf23d16c24dc2e1857e7941457e02ddd4a2e83441b767302bf6c4322bb88a087703bf2480031ac0bc7aa5e86470607
data/README.md CHANGED
@@ -103,12 +103,22 @@ Funes gives you fine-grained control over when and how projections run:
103
103
 
104
104
  Guides and full API documentation are available at [docs.funes.org](https://docs.funes.org).
105
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
+
106
114
  ## Compatibility
107
115
 
108
- - **Ruby:** 3.1, 3.2, 3.3, 3.4
109
- - **Rails:** 7.1, 7.2, 8.0, 8.1
116
+ Funes supports the following runtimes:
117
+
118
+ - **Ruby** 3.1 or newer
119
+ - **Rails** 7.2 or newer
110
120
 
111
- Rails 8.0+ requires Ruby 3.2 or higher.
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.
112
122
 
113
123
  ## License
114
124
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -230,33 +230,72 @@ module Funes
230
230
  # # Race condition detected, retry logic here
231
231
  # end
232
232
  def append(new_event, at: nil)
233
- return new_event unless new_event.valid?
234
-
235
- occurred_at = resolve_proper_occurred_at(new_event, at)
236
-
237
- if consistency_projection.present?
238
- materialization = compute_projection_with_new_event(consistency_projection, new_event, occurred_at)
239
- transfer_interpretation_errors(new_event)
240
- return new_event if materialization.invalid? || new_event.invalid?
241
- end
242
-
243
- ActiveRecord::Base.transaction do
244
- begin
245
- @instance_new_events << new_event.persist!(@idx, incremented_version, at: occurred_at)
246
- run_transactional_projections
247
- rescue ActiveRecord::RecordNotUnique
248
- new_event._event_entry = nil
249
- new_event.errors.add(:base, I18n.t("funes.events.racing_condition_on_insert"))
250
- raise ActiveRecord::Rollback
251
- rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => e
252
- new_event._event_entry = nil
253
- raise e
254
- end
255
- end
256
-
257
- schedule_async_projections unless new_event.errors.any?
233
+ do_append(new_event, at: at, raise_on_failure: false)
234
+ end
258
235
 
259
- new_event
236
+ # Append a new event to the stream, raising on any failure.
237
+ #
238
+ # Behaves like {#append}, but raises `ActiveRecord::RecordInvalid` whenever the event cannot be
239
+ # persisted — mirroring the relationship between `ActiveRecord::Base#save` and `#save!`. Because
240
+ # a real exception is raised, any enclosing `ActiveRecord::Base.transaction` block rolls back,
241
+ # which is what you want when you need host-managed transactional control around `append`.
242
+ #
243
+ # The failed event is always queryable after the rescue: `event.persisted?` returns false and
244
+ # `event.errors` is populated (for event-level failures such as validation, consistency
245
+ # rejection, or version conflict).
246
+ #
247
+ # Failure modes:
248
+ # - Event's own validation fails → raises `ActiveRecord::RecordInvalid` with the event as `record`.
249
+ # - Consistency projection rejects the event → raises `ActiveRecord::RecordInvalid` with the
250
+ # event as `record` (state / interpretation errors transferred to the event).
251
+ # - Version conflict (race condition on insert) → raises `ActiveRecord::RecordInvalid` with the
252
+ # event as `record` and a racing-condition message on `event.errors[:base]`.
253
+ # - A transactional projection's persistence fails → the original
254
+ # `ActiveRecord::StatementInvalid` / `ActiveRecord::RecordInvalid` is re-raised untouched (its
255
+ # `record` is the projection's materialization model, not the event). `event.persisted?` is
256
+ # still false after the rescue.
257
+ #
258
+ # Async projections are only enqueued when `append!` returns successfully. When `append!` is
259
+ # nested inside a user-opened `ActiveRecord::Base.transaction` that later rolls back, Rails'
260
+ # `enqueue_after_transaction_commit` discards the deferred enqueue so no job runs.
261
+ #
262
+ # @param [Funes::Event] new_event The event to append to the stream.
263
+ # @param [Time, Date, nil] at See {#append}.
264
+ # @return [Funes::Event] The persisted event.
265
+ # @raise [ActiveRecord::RecordInvalid] When the event cannot be persisted. `e.record` is the
266
+ # failed event (for event-level failures) or a projection materialization model (for
267
+ # projection validation failures).
268
+ # @raise [ActiveRecord::StatementInvalid] When a transactional projection fails with a database
269
+ # constraint violation.
270
+ # @raise [Funes::ConflictingActualTimeError] See {#append}.
271
+ # @raise [Funes::MissingActualTimeAttributeError] See {#append}.
272
+ #
273
+ # @example Host-managed transaction with a sibling AR update
274
+ # event = SomeEvent.new(some: "value")
275
+ # begin
276
+ # ActiveRecord::Base.transaction do
277
+ # some_model.update!(some: "value")
278
+ # SomeEventStream.for(stream_id).append!(event)
279
+ # end
280
+ # rescue ActiveRecord::RecordInvalid
281
+ # event.persisted? # => false
282
+ # event.errors.any? # => true
283
+ # end
284
+ #
285
+ # @example Two appends in a single transaction — either both commit or neither does
286
+ # event_1 = SomeEvent.new(some: "value")
287
+ # event_2 = OtherEvent.new(some: "value")
288
+ # begin
289
+ # ActiveRecord::Base.transaction do
290
+ # SomeEventStream.for(stream_id).append!(event_1)
291
+ # OtherEventStream.for(other_stream_id).append!(event_2)
292
+ # end
293
+ # rescue ActiveRecord::RecordInvalid
294
+ # event_1.persisted? # => false
295
+ # event_2.persisted? # => false
296
+ # end
297
+ def append!(new_event, at: nil)
298
+ do_append(new_event, at: at, raise_on_failure: true)
260
299
  end
261
300
 
262
301
  # @!visibility private
@@ -291,12 +330,19 @@ module Funes
291
330
  # include those where `occurred_at <= at` before projection. When `as_of:` is provided,
292
331
  # it overrides the stream's record-time boundary, filtering events in Ruby by `created_at`.
293
332
  #
333
+ # Mirrors `ActiveRecord::Base.find`: if there are no events to replay — either because the
334
+ # stream has no events at all or because `as_of:` / `at:` filtering leaves none in scope —
335
+ # raises {ActiveRecord::RecordNotFound}. In a Rails controller, this lets the host app's
336
+ # middleware render a 404 automatically, without any rescue wiring.
337
+ #
294
338
  # @param projection_class [Class<Funes::Projection>] The projection class to use.
295
339
  # @param [Time, nil] as_of Optional record-time override. When provided, only events with
296
340
  # `created_at <= as_of` are considered, overriding the stream's own `@as_of`.
297
341
  # @param [Time, nil] at Optional actual-time reference. When provided, only events with
298
342
  # `occurred_at <= at` are included in the projection.
299
343
  # @return [Object] The materialized state as defined by the projection's materialization model.
344
+ # @raise [ActiveRecord::RecordNotFound] when the filtered event list is empty — either
345
+ # because the stream has no events or because `as_of:` / `at:` excludes them all.
300
346
  #
301
347
  # @example Project current state
302
348
  # stream = OrderEventStream.for("order-123")
@@ -313,9 +359,36 @@ module Funes
313
359
  # snapshot = stream.projected_with(SalaryProjection,
314
360
  # as_of: Time.new(2025, 3, 1),
315
361
  # at: Time.new(2025, 2, 20))
362
+ #
363
+ # @example Automatic 404 in a Rails controller
364
+ # class OrdersController < ApplicationController
365
+ # def show
366
+ # stream = OrderEventStream.for(params[:id])
367
+ # @order = stream.projected_with(OrderSummaryProjection)
368
+ # end
369
+ # end
370
+ # # If no events exist for params[:id], Rails renders its 404 page automatically.
316
371
  def projected_with(projection_class, as_of: nil, at: nil)
317
372
  source_events = as_of ? filter_by_record_time(events, as_of) : events
318
373
  target_events = at ? filter_by_actual_time(source_events, at) : source_events
374
+
375
+ if target_events.empty?
376
+ materialization_model = projection_class.instance_variable_get(:@materialization_model)
377
+ model_name = materialization_model&.name
378
+ Rails.logger.info(
379
+ "[Funes] projected_with found no events for " \
380
+ "#{self.class.name} idx=#{idx.inspect} " \
381
+ "projection=#{projection_class.name} " \
382
+ "as_of=#{as_of.inspect} at=#{at.inspect}"
383
+ )
384
+ raise ActiveRecord::RecordNotFound.new(
385
+ "Couldn't find #{model_name} for #{self.class.name} #{idx.inspect}",
386
+ model_name,
387
+ "idx",
388
+ idx
389
+ )
390
+ end
391
+
319
392
  projection_class.process_events(target_events, at: at)
320
393
  end
321
394
 
@@ -338,6 +411,46 @@ module Funes
338
411
  end
339
412
 
340
413
  private
414
+ def do_append(new_event, at:, raise_on_failure:)
415
+ unless new_event.valid?
416
+ raise ActiveRecord::RecordInvalid.new(new_event) if raise_on_failure
417
+ return new_event
418
+ end
419
+
420
+ occurred_at = resolve_proper_occurred_at(new_event, at)
421
+
422
+ if consistency_projection.present?
423
+ materialization = compute_projection_with_new_event(consistency_projection, new_event, occurred_at)
424
+ transfer_interpretation_errors(new_event)
425
+ if materialization.invalid? || new_event.invalid?
426
+ raise ActiveRecord::RecordInvalid.new(new_event) if raise_on_failure
427
+ return new_event
428
+ end
429
+ end
430
+
431
+ ActiveRecord::Base.transaction do
432
+ begin
433
+ @instance_new_events << new_event.persist!(@idx, incremented_version, at: occurred_at)
434
+ ActiveRecord::Base.connection.current_transaction.after_rollback do
435
+ new_event._event_entry = nil
436
+ end
437
+ run_transactional_projections
438
+ rescue ActiveRecord::RecordNotUnique
439
+ new_event._event_entry = nil
440
+ new_event.errors.add(:base, I18n.t("funes.events.racing_condition_on_insert"))
441
+ raise ActiveRecord::RecordInvalid.new(new_event) if raise_on_failure
442
+ raise ActiveRecord::Rollback
443
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => e
444
+ new_event._event_entry = nil
445
+ raise e
446
+ end
447
+ end
448
+
449
+ schedule_async_projections unless new_event.errors.any?
450
+
451
+ new_event
452
+ end
453
+
341
454
  def run_transactional_projections
342
455
  transactional_projections.each do |projection_class|
343
456
  Funes::PersistProjectionJob.perform_now(self, projection_class, last_event_creation_date,
@@ -1,5 +1,7 @@
1
1
  module Funes
2
2
  class PersistProjectionJob < ApplicationJob
3
+ self.enqueue_after_transaction_commit = true
4
+
3
5
  def perform(event_stream, projection_class, as_of = nil, at = nil)
4
6
  events = as_of ? event_stream.events.select { |e| e.created_at <= as_of } : event_stream.events
5
7
  projection_class.materialize!(events, event_stream.idx, at: at)
@@ -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
- materialized_model_instance = materialized_model_instance_based_on(state)
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
- materialized_model_instance
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 materialization_model_is_persistable?
217
- @materialization_model.present? && @materialization_model <= ActiveRecord::Base
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
- raise ActiveRecord::RecordInvalid.new(state) unless state.valid?
222
- @materialization_model.upsert(state.attributes, unique_by: :idx)
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
@@ -1,3 +1,3 @@
1
1
  module Funes
2
- VERSION = "0.2.1"
2
+ VERSION = "0.2.3"
3
3
  end
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.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vinícius Almeida da Silva
@@ -15,28 +15,34 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.1'
18
+ version: '7.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.1'
25
+ version: '7.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: minitest
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "<"
30
+ - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: '5.26'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '6'
33
36
  type: :development
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
36
39
  requirements:
37
- - - "<"
40
+ - - ">="
38
41
  - !ruby/object:Gem::Version
39
42
  version: '5.26'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '6'
40
46
  - !ruby/object:Gem::Dependency
41
47
  name: database_cleaner-active_record
42
48
  requirement: !ruby/object:Gem::Requirement
@@ -210,6 +216,7 @@ files:
210
216
  - lib/funes/event_metainformation_builder.rb
211
217
  - lib/funes/inspection.rb
212
218
  - lib/funes/invalid_event_metainformation.rb
219
+ - lib/funes/invalid_materialization_state.rb
213
220
  - lib/funes/missing_actual_time_attribute_error.rb
214
221
  - lib/funes/unknown_event.rb
215
222
  - lib/funes/unknown_materialization_model.rb