tcb 0.6.2 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d004e21f0c4555988f0a5b84e40a4ae4710933b3e862ab964a6a4bc07cd02cf
4
- data.tar.gz: 684c9a07870739ebec829c872f6c4c71d455b0919d369d0f9badd000fceb28d3
3
+ metadata.gz: e9fdc75ccfefebb2cb421260d89276f946b42f3d0f9e7ed90438b1d2cce1da8c
4
+ data.tar.gz: d48eee8151fc7ea5b2339f8949fc68bd6f04b61062007aff2f335e2f2f1b367d
5
5
  SHA512:
6
- metadata.gz: 71b0fe98490c7a0c1d995b7eaf7612976690306c55c1ccc904f4038089f11c3f6389e03afd9474fd1a058c28de4905fdfca811cb4e88fcf77119a361cde3ff3f
7
- data.tar.gz: a433451a28f9fe1bd295db4fe40e4760069d4d4915ec8807f8f35f2791df6821dd86f1e24b7c57bbb470dc9b4d618f540e26862a61461e71af9c839bc881a661
6
+ metadata.gz: 5b8d0a0de064464890a11e71649dbb587fa032cccfd6d57ebaebfe350303ad725519cf7c8b66e8ec48d9b6092d8082e8167daa9d81d3d1cdc632cb3c50f67107
7
+ data.tar.gz: cccbab25a067c195d236f8e83796099d4eae236061021d3fe9a7547016a79b5a4f1bb22d525d9ce8cd506d5eb14be865c31e0417dbb1cc5ef9c8eb2ed52384d3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-05-22
9
+
10
+ ### Added
11
+
12
+ - `TCB.reactions_for(event_class)` — returns all handler classes registered for an event across all domain modules; returns `[]` if none
13
+ - `ensure_reaction` DSL — declares guaranteed delivery handlers via outbox pattern; used as `on EventClass, ensure_reaction(Handler)`
14
+ - `TCB::OutboxEntry` — value object carrying outbox entry state: `id`, `event_id`, `stream_id`, `version`, `handler_class`, `status`, `locked_at`, `delivered_at`, `error`, `created_at`
15
+ - `TCB::OutboxStore::InMemory` — thread-safe in-memory outbox store for tests
16
+ - `TCB::OutboxStore::ActiveRecord` — ActiveRecord persistence adapter for outbox entries; drop-in replacement for `OutboxStore::InMemory`
17
+ - `TCB::OutboxRelay` — single polling cycle: recover stale locks → lock pending → fetch envelopes → invoke handler → mark delivered/failed
18
+ - `Configuration#outbox_store_class` — declares which outbox store adapter to use (`OutboxStore::InMemory` or `OutboxStore::ActiveRecord`); store is instantiated per domain module during configuration
19
+ - `Configuration#outbox_registrations` — collected outbox handler registrations across domain modules, each carrying a reference to its domain's store instance
20
+ - `DomainContext#outbox_table_name` — derives per-domain outbox table name (`..._outbox` suffix)
21
+ - Per-domain `OutboxRecord` AR model defined dynamically for domain modules with outbox registrations; analogous to `EventRecord`
22
+ - Rails generator `tcb:outbox` — generates outbox migration and job scaffold per domain module
23
+ - Outbox migration template — table with `id` (string UUID, primary key), `event_id`, `stream_id`, `version`, `handler_class`, `status`, `locked_at`, `delivered_at`, `error`, `created_at`; indexes on `:status`, `[:status, :locked_at]`, `[:stream_id, :version]`
24
+ - Outbox job template — `ApplicationJob` wrapper around `OutboxRelay` for SolidQueue/Sidekiq integration
25
+
8
26
  ## [0.6.2] - 2026-05-07
9
27
 
10
28
  ### Fixed
data/README.md CHANGED
@@ -32,6 +32,29 @@ Clean domain code pays compound interest. It's easier to reason about, easier to
32
32
  and easier for AI agents to work with. TCB keeps your domain that way.
33
33
  Rails takes care of the rest.
34
34
 
35
+
36
+ ## Contents
37
+
38
+ - [Installation](#installation)
39
+ - [Event Bus](#event-bus)
40
+ - [TCB::HandlesEvents](#tcbhandlesevents)
41
+ - [TCB::HandlesCommands](#tcbhandlescommands)
42
+ - [Domain-Driven Design](#domain-driven-design)
43
+ - [Guaranteed Delivery with the Outbox Pattern](#guaranteed-delivery-with-the-outbox-pattern)
44
+ - [Configuration](#configuration)
45
+ - [Reading Events](#reading-events)
46
+ - [Correlation and Causation Tracking](#correlation-and-causation-tracking)
47
+ - [Reading a Correlation Chain](#reading-a-correlation-chain)
48
+ - [Inspecting Reactions](#inspecting-reactions)
49
+ - [Event Store Adapters](#event-store-adapters)
50
+ - [Generators](#generators)
51
+ - [Error Handling](#error-handling)
52
+ - [Graceful Shutdown](#graceful-shutdown)
53
+ - [Testing](#testing)
54
+ - [Why `Data.define`](#why-datadefine)
55
+
56
+ ---
57
+
35
58
  ## Installation
36
59
 
37
60
  Add this line to your application's Gemfile:
@@ -163,7 +186,7 @@ module Notifications
163
186
  end
164
187
  ```
165
188
 
166
- Event classes can come from anywhere. Cross-module reactions are the norm, not the exception. Each handler is isolated. Ine failure does not prevent others from executing.
189
+ Event classes can come from anywhere. Cross-module reactions are the norm, not the exception. Each handler is isolated. One failure does not prevent others from executing.
167
190
 
168
191
  Domain modules are declared once at the top level, before infrastructure is configured.
169
192
  This is the only place TCB needs to know about your bounded contexts. All reactions,
@@ -255,32 +278,24 @@ The domain module is a boundary. Everything inside speaks the domain language. N
255
278
  module Sales
256
279
  include TCB::Domain
257
280
 
258
- # Facade
259
281
  def self.place!(order_id:, customer:)
260
282
  TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
261
283
  end
262
284
 
263
- # Events
264
285
  OrderPlaced = Data.define(:order_id, :customer)
265
286
 
266
- # Commands
267
287
  PlaceOrder = Data.define(:order_id, :customer) do
268
288
  def validate!
269
289
  raise ArgumentError, "customer required" if customer.nil?
270
290
  end
271
291
  end
272
292
 
273
- # Persistence
274
293
  persist events(
275
294
  OrderPlaced,
276
295
  stream_id_from_event: :order_id
277
296
  )
278
297
 
279
- # Commands
280
298
  handle PlaceOrder, with(PlaceOrderHandler)
281
-
282
- # Reactions
283
- on OrderPlaced, react_with(Warehouse::ReserveStock)
284
299
  end
285
300
  ```
286
301
 
@@ -392,6 +407,103 @@ end
392
407
 
393
408
  ---
394
409
 
410
+ ## Guaranteed Delivery with the Outbox Pattern
411
+
412
+ `on Event, Handler` delivers reactions synchronously and best-effort. If the process crashes mid-dispatch, or a handler calls an external service that fails, the reaction is lost. For cross-domain reactions that must eventually succeed, like sending emails, enqueuing jobs, notifying external systems, TCB offers `ensure_reaction`.
413
+
414
+ ```ruby
415
+ module Invoicing
416
+ include TCB::Domain
417
+
418
+ OrderPlaced = Data.define(:order_id)
419
+
420
+ persist events(OrderPlaced, stream_id_from_event: :order_id)
421
+
422
+ on OrderPlaced, ensure_reaction(SendInvoice, NotifyAccounting)
423
+ end
424
+ ```
425
+
426
+ `ensure_reaction` does not call your handlers inline. Instead, `TCB.record` writes outbox entries atomically alongside the event, in the same database transaction. A background job polls the outbox, invokes each handler, and marks the entry delivered. If the process crashes before the job runs, the entries remain pending and will be picked up on the next poll.
427
+
428
+ > `ensure_reaction` requires the event to be persisted. `persist events(...)` must be declared in the same domain module.
429
+
430
+ ### Setup
431
+
432
+ If your outbox handlers call Rails infrastructure — ActiveJob, mailers, or other application classes — place your full configuration in `config/initializers/tcb.rb`. This ensures Rails is fully loaded when handlers execute:
433
+
434
+ ```ruby
435
+ # config/initializers/tcb.rb
436
+ Rails.application.config.to_prepare do
437
+ TCB.reset!
438
+ TCB.domain_modules = [Sales, Warehouse, Notifications]
439
+
440
+ TCB.configure do |c|
441
+ case Rails.env
442
+ when "test"
443
+ c.event_bus = TCB::EventBus.new(sync: true)
444
+ c.event_store = TCB::EventStore::InMemory.new
445
+ c.outbox_store_class = TCB::OutboxStore::InMemory
446
+ when "development"
447
+ c.event_bus = TCB::EventBus.new(handle_signals: false, shutdown_timeout: 10.0)
448
+ c.event_store = TCB::EventStore::ActiveRecord.new
449
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord
450
+ when "production"
451
+ c.event_bus = TCB::EventBus.new(handle_signals: true, shutdown_timeout: 30.0)
452
+ c.event_store = TCB::EventStore::ActiveRecord.new
453
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord
454
+ end
455
+ end
456
+ end
457
+ ```
458
+
459
+ `OutboxStore::InMemory` is a drop-in replacement for tests. Entries live in memory and do not require a migration.
460
+
461
+ ### Generator
462
+
463
+ Generate the outbox migration and job scaffold for a domain module:
464
+
465
+ ```CLI
466
+ bin/rails generate tcb:outbox invoicing
467
+ ```
468
+
469
+ This creates:
470
+
471
+ - `db/migrate/..._create_invoicing_outbox.rb` — the outbox table
472
+ - `app/jobs/invoicing_outbox_job.rb` — a job that runs one relay cycle
473
+
474
+ ```ruby
475
+ class InvoicingOutboxJob < ApplicationJob
476
+ queue_as :default
477
+
478
+ def perform
479
+ TCB::OutboxRelay.new(
480
+ outbox_store: Invoicing::OutboxRecord,
481
+ event_store: TCB.config.event_store,
482
+ lock_timeout: 300
483
+ ).run
484
+ end
485
+ end
486
+ ```
487
+
488
+ Schedule this job to run periodically. Each `perform` call runs one polling cycle: recover stale locks → lock pending entries → fetch events → invoke handlers → mark delivered or failed.
489
+
490
+
491
+ With Solid Queue (Rails 8 default), add a recurring job to `config/recurring.yml`:
492
+
493
+ ```yaml
494
+ invoicing_outbox:
495
+ class: InvoicingOutboxJob
496
+ schedule: every 30 seconds
497
+ ```
498
+
499
+ Pick an interval based on your latency tolerance. Every 30 seconds is a reasonable starting point for most applications.
500
+
501
+ ### Failure handling
502
+
503
+ If a handler raises, the entry is marked `failed` and the error message is stored. Other entries in the same cycle continue processing. Failed entries are not retried automatically. Retry is the responsibility of your job scheduler. Schedule the job to run periodically and retries happen naturally on the next cycle.
504
+
505
+ ---
506
+
395
507
  ## Configuration
396
508
 
397
509
  ### Domain modules
@@ -414,11 +526,12 @@ Infrastructure is environment-specific. Configure it in each environment file so
414
526
  Rails.application.config.to_prepare do
415
527
  TCB.reset!
416
528
  TCB.configure do |c|
417
- c.event_bus = TCB::EventBus.new(
418
- handle_signals: false, # Rails manages process signals in development
529
+ c.event_bus = TCB::EventBus.new(
530
+ handle_signals: false,
419
531
  shutdown_timeout: 10.0
420
532
  )
421
- c.event_store = TCB::EventStore::ActiveRecord.new
533
+ c.event_store = TCB::EventStore::ActiveRecord.new
534
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord # required if any domain uses ensure_reaction
422
535
  end
423
536
  end
424
537
  ```
@@ -430,11 +543,12 @@ end
430
543
  Rails.application.config.to_prepare do
431
544
  TCB.reset!(graceful_shutdown_time: 10.0)
432
545
  TCB.configure do |c|
433
- c.event_bus = TCB::EventBus.new(
546
+ c.event_bus = TCB::EventBus.new(
434
547
  handle_signals: true,
435
548
  shutdown_timeout: 30.0
436
549
  )
437
- c.event_store = TCB::EventStore::ActiveRecord.new
550
+ c.event_store = TCB::EventStore::ActiveRecord.new
551
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord
438
552
  end
439
553
  end
440
554
  ```
@@ -445,8 +559,9 @@ end
445
559
  # config/environments/test.rb
446
560
  Rails.application.config.after_initialize do
447
561
  TCB.configure do |c|
448
- c.event_bus = TCB::EventBus.new(sync: true)
449
- c.event_store = TCB::EventStore::InMemory.new
562
+ c.event_bus = TCB::EventBus.new(sync: true)
563
+ c.event_store = TCB::EventStore::InMemory.new
564
+ c.outbox_store_class = TCB::OutboxStore::InMemory # in-memory, no migration needed
450
565
  end
451
566
  end
452
567
  ```
@@ -463,6 +578,25 @@ Each domain module gets its own database table. Domains stay isolated at the per
463
578
 
464
579
  ---
465
580
 
581
+ ### Serialization
582
+
583
+ TCB serializes events to YAML. `Symbol`, `Time`, `Date`, `BigDecimal`, and your `Data` events are permitted by default during deserialization.
584
+
585
+ If your events carry attributes of other types — such as `ActiveSupport::TimeWithZone` when using `Time.zone.now` — declare them explicitly:
586
+
587
+ ```ruby
588
+ TCB.configure do |c|
589
+ c.extra_serialization_classes = [
590
+ ActiveSupport::TimeWithZone,
591
+ ActiveSupport::TimeZone,
592
+ ]
593
+ end
594
+ ```
595
+
596
+ This is optional. Omit it if your event attributes use only plain Ruby types.
597
+
598
+ ---
599
+
466
600
  ## Reading Events
467
601
 
468
602
  Events are stored per aggregate stream. Query them by domain module and aggregate id. The result is always ordered by version. For large streams, `in_batches` uses keyset pagination and keeps memory usage flat.
@@ -559,6 +693,22 @@ Results are ordered by `occurred_at` across all domains. Each result is a `TCB::
559
693
 
560
694
  ---
561
695
 
696
+ ## Inspecting Reactions
697
+
698
+ To see which handlers are registered for an event class across all domain modules:
699
+
700
+ ```ruby
701
+ TCB.reactions_for(Sales::OrderPlaced)
702
+ # => [Warehouse::ReserveStock]
703
+
704
+ TCB.reactions_for(Warehouse::StockReserved)
705
+ # => [Notifications::NotifyCustomer]
706
+ ```
707
+
708
+ Returns an empty array if no reactions are registered. Useful for verifying topology at a glance or asserting reaction wiring in tests.
709
+
710
+ ---
711
+
562
712
  ## Event Store Adapters
563
713
 
564
714
  ### In-Memory (for tests)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ module Generators
5
+ class OutboxGenerator < Rails::Generators::Base
6
+ namespace "tcb:outbox"
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ argument :module_name, type: :string
10
+
11
+ def create_migration
12
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
13
+ template "migration.rb.tt", "db/migrate/#{timestamp}_create_#{table_name}.rb"
14
+ end
15
+
16
+ def create_job
17
+ template "job.rb.tt", "app/jobs/#{job_file_name}.rb"
18
+ end
19
+
20
+ private
21
+
22
+ def table_name
23
+ "#{module_name.underscore}_outbox"
24
+ end
25
+
26
+ def migration_class_name
27
+ "Create#{module_name.camelize}Outbox"
28
+ end
29
+
30
+ def module_class_name
31
+ module_name.camelize
32
+ end
33
+
34
+ def job_class_name
35
+ "#{module_name.camelize}OutboxJob"
36
+ end
37
+
38
+ def job_file_name
39
+ "#{module_name.underscore}_outbox_job"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ class <%= job_class_name %> < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform
5
+ TCB::OutboxRelay.new(
6
+ outbox_store: <%= module_class_name %>::OutboxRecord,
7
+ event_store: TCB.config.event_store,
8
+ lock_timeout: 300
9
+ ).run
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :<%= table_name %>, id: false, primary_key: :id do |t|
4
+ t.string :id, null: false
5
+ t.string :event_id, null: false
6
+ t.string :stream_id, null: false
7
+ t.integer :version, null: false
8
+ t.string :handler_class, null: false
9
+ t.string :status, null: false, default: "pending"
10
+ t.datetime :locked_at
11
+ t.datetime :delivered_at
12
+ t.text :error
13
+ t.datetime :created_at, null: false
14
+ end
15
+
16
+ add_index :<%= table_name %>, :status
17
+ add_index :<%= table_name %>, [:status, :locked_at]
18
+ add_index :<%= table_name %>, [:stream_id, :version]
19
+ end
20
+ end
@@ -30,11 +30,24 @@ module TCB
30
30
  @event_store
31
31
  end
32
32
 
33
+ def outbox_store_class=(store)
34
+ @outbox_store_class = store
35
+ end
36
+
37
+ def outbox_store_class
38
+ @outbox_store_class
39
+ end
40
+
41
+ def outbox_registrations
42
+ @outbox_registrations ||= []
43
+ end
44
+
33
45
  def domain_modules=(modules)
34
46
  @domain_modules = modules
35
47
  flush_domain_modules
36
48
  flush_command_handlers
37
49
  flush_persist_registrations
50
+ flush_outbox_registrations
38
51
  end
39
52
 
40
53
  def domain_modules
@@ -108,8 +121,63 @@ module TCB
108
121
  domain_module.persist_registrations.each do |registration|
109
122
  @persist_registrations << registration.with(context: context)
110
123
  end
111
- define_event_record_for(domain_module)
124
+ define_event_record_for(domain_module) if active_record_store?
125
+ end
126
+ end
127
+
128
+ def active_record_store?
129
+ defined?(::ActiveRecord) && @event_store.is_a?(TCB::EventStore::ActiveRecord)
130
+ end
131
+
132
+ def flush_outbox_registrations
133
+ @outbox_registrations = []
134
+ @domain_modules.each do |domain_module|
135
+ next unless domain_module.respond_to?(:outbox_registrations)
136
+ next unless domain_module.outbox_registrations.any?
137
+
138
+ validate_outbox_registrations!(domain_module)
139
+ store = build_outbox_store(domain_module)
140
+
141
+ domain_module.outbox_registrations.each do |r|
142
+ @outbox_registrations << r.with(outbox_store: store)
143
+ end
144
+ end
145
+ end
146
+
147
+
148
+ def validate_outbox_registrations!(domain_module)
149
+ persisted_event_classes = domain_module.persist_registrations.flat_map(&:event_classes)
150
+
151
+ domain_module.outbox_registrations.map(&:event_class).uniq.each do |event_class|
152
+ unless persisted_event_classes.include?(event_class)
153
+ raise ConfigurationError,
154
+ "#{event_class} has ensure_reaction in #{domain_module} but is not persisted."
155
+ end
156
+ end
157
+
158
+ unless @outbox_store_class
159
+ raise ConfigurationError,
160
+ "#{domain_module} has outbox registrations but no outbox_store_class is configured."
161
+ end
162
+ end
163
+
164
+ def build_outbox_store(domain_module)
165
+ if active_record_store?
166
+ define_outbox_record_for(domain_module)
167
+ @outbox_store_class.new(domain_module.const_get(:OutboxRecord))
168
+ else
169
+ @outbox_store_class.new(nil)
170
+ end
171
+ end
172
+
173
+ def define_outbox_record_for(domain_module)
174
+ return if domain_module.const_defined?(:OutboxRecord, false)
175
+
176
+ klass = Class.new(::ActiveRecord::Base) do
177
+ self.table_name = DomainContext.from_module(domain_module).outbox_table_name
178
+ self.primary_key = "id"
112
179
  end
180
+ domain_module.const_set(:OutboxRecord, klass)
113
181
  end
114
182
 
115
183
  def define_event_record_for(domain_module)
@@ -121,4 +189,4 @@ module TCB
121
189
  domain_module.const_set(:EventRecord, klass)
122
190
  end
123
191
  end
124
- end
192
+ end
@@ -5,6 +5,8 @@ module TCB
5
5
  NAMESPACE_SEPARATOR = "/"
6
6
  TABLE_SEPARATOR = "__"
7
7
  TABLE_SUFFIX = "_events"
8
+ OUTBOX_SUFFIX = "_outbox"
9
+
8
10
 
9
11
  def self.from_module(domain_module)
10
12
  value = domain_module.name
@@ -25,5 +27,11 @@ module TCB
25
27
  .gsub(NAMESPACE_SEPARATOR, TABLE_SEPARATOR)
26
28
  .concat(TABLE_SUFFIX)
27
29
  end
30
+
31
+ def outbox_table_name
32
+ value
33
+ .gsub(NAMESPACE_SEPARATOR, TABLE_SEPARATOR)
34
+ .concat(OUTBOX_SUFFIX)
35
+ end
28
36
  end
29
37
  end
@@ -37,6 +37,15 @@ module TCB
37
37
  .then { |e| limit ? e.first(limit) : e }
38
38
  end
39
39
 
40
+ def read_by_event_ids(event_ids)
41
+ @mutex.synchronize do
42
+ @streams.values
43
+ .flatten
44
+ .select { |envelope| event_ids.include?(envelope.event_id) }
45
+ .each_with_object({}) { |envelope, hash| hash[envelope.event_id] = envelope }
46
+ end
47
+ end
48
+
40
49
  def read_by_correlation(correlation_id, context:, occurred_after: nil, occurred_before: nil)
41
50
  @mutex.synchronize { @streams.values.flatten.dup }
42
51
  .select { |e| e.stream_id.start_with?(context) }
@@ -4,20 +4,33 @@ module TCB
4
4
  module HandlesEvents
5
5
  EventHandlerRegistration = Data.define(:event_class, :handlers)
6
6
  PersistRegistration = Data.define(:event_classes, :stream_id_from_event, :context)
7
+ OutboxRegistration = Data.define(:event_class, :handler, :outbox_store)
7
8
 
8
9
  def self.included(base)
9
10
  base.extend(ClassMethods)
10
11
  base.instance_variable_set(:@event_handler_registrations, [])
11
12
  base.instance_variable_set(:@persist_registrations, [])
13
+ base.instance_variable_set(:@outbox_registrations, [])
12
14
  end
13
15
 
14
16
  module ClassMethods
17
+ def on(event_class, registration)
18
+ case registration
19
+ in EventHandlerRegistration => r
20
+ @event_handler_registrations << r.with(event_class: event_class)
21
+ in [OutboxRegistration, *] => registrations
22
+ registrations.each do |r|
23
+ @outbox_registrations << r.with(event_class: event_class)
24
+ end
25
+ end
26
+ end
27
+
15
28
  def react_with(*handlers)
16
29
  EventHandlerRegistration.new(event_class: :undefined, handlers: handlers)
17
30
  end
18
31
 
19
- def on(event_class, registration)
20
- @event_handler_registrations << registration.with(event_class: event_class)
32
+ def ensure_reaction(*handlers)
33
+ handlers.map { |handler| OutboxRegistration.new(event_class: :undefined, handler: handler, outbox_store: nil) }
21
34
  end
22
35
 
23
36
  def persist(registration)
@@ -39,6 +52,10 @@ module TCB
39
52
  def persist_registrations
40
53
  @persist_registrations
41
54
  end
55
+
56
+ def outbox_registrations
57
+ @outbox_registrations
58
+ end
42
59
  end
43
60
  end
44
- end
61
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ TCB::OutboxEntry = Data.define(
4
+ :id,
5
+ :event_id,
6
+ :stream_id,
7
+ :version,
8
+ :handler_class,
9
+ :status,
10
+ :locked_at,
11
+ :delivered_at,
12
+ :error,
13
+ :created_at
14
+ )
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class OutboxRelay
5
+ def initialize(outbox_store:, event_store:, lock_timeout:)
6
+ @outbox_store = outbox_store
7
+ @event_store = event_store
8
+ @lock_timeout = lock_timeout
9
+ end
10
+
11
+ def run
12
+ recover_stale_locks
13
+ entries = lock_pending
14
+ envelopes = fetch_envelopes(entries)
15
+ entries.each { |entry| process(entry, envelopes[entry.event_id]) }
16
+ end
17
+
18
+ private
19
+
20
+ def recover_stale_locks
21
+ @outbox_store.recover_stale_locks(older_than: Time.now - @lock_timeout)
22
+ end
23
+
24
+ def lock_pending
25
+ @outbox_store
26
+ .pending
27
+ .map { |e| @outbox_store.lock(e) }
28
+ end
29
+
30
+ def fetch_envelopes(entries)
31
+ @event_store.read_by_event_ids(entries.map(&:event_id))
32
+ end
33
+
34
+ def process(entry, envelope)
35
+ Object.const_get(entry.handler_class).new.call(envelope.event)
36
+ @outbox_store.mark_delivered(entry)
37
+ rescue => error
38
+ @outbox_store.mark_failed(entry, error: error)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module TCB
6
+ module OutboxStore
7
+ class ActiveRecord
8
+ def initialize(model)
9
+ @model = model
10
+ end
11
+
12
+ def insert(event_id:, stream_id:, version:, handler_class:)
13
+ id = SecureRandom.uuid
14
+ now = Time.now
15
+
16
+ @model.create!(
17
+ id: id,
18
+ event_id: event_id,
19
+ stream_id: stream_id,
20
+ version: version,
21
+ handler_class: handler_class.name,
22
+ status: "pending",
23
+ locked_at: nil,
24
+ delivered_at: nil,
25
+ error: nil,
26
+ created_at: now
27
+ )
28
+
29
+ OutboxEntry.new(
30
+ id: id,
31
+ event_id: event_id,
32
+ stream_id: stream_id,
33
+ version: version,
34
+ handler_class: handler_class.name,
35
+ status: :pending,
36
+ locked_at: nil,
37
+ delivered_at: nil,
38
+ error: nil,
39
+ created_at: now
40
+ )
41
+ end
42
+
43
+ def all
44
+ @model.all.map { |r| to_entry(r) }
45
+ end
46
+
47
+ def pending
48
+ @model.where(status: "pending").order(:stream_id, :version).map { |r| to_entry(r) }
49
+ end
50
+
51
+ def lock(entry, locked_at: Time.now)
52
+ @model.where(id: entry.id).update_all(status: "locked", locked_at: locked_at)
53
+ entry.with(status: :locked, locked_at: locked_at)
54
+ end
55
+
56
+ def mark_delivered(entry, delivered_at: Time.now)
57
+ @model.where(id: entry.id).update_all(status: "delivered", delivered_at: delivered_at)
58
+ entry.with(status: :delivered, delivered_at: delivered_at)
59
+ end
60
+
61
+ def mark_failed(entry, error:)
62
+ @model.where(id: entry.id).update_all(status: "failed", error: error.message)
63
+ entry.with(status: :failed, error: error.message)
64
+ end
65
+
66
+ def recover_stale_locks(older_than:)
67
+ stale = @model.where(status: "locked").where("locked_at < ?", older_than)
68
+ stale.map do |record|
69
+ @model.where(id: record.id).update_all(status: "pending", locked_at: nil)
70
+ to_entry(record).with(status: :pending, locked_at: nil)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def to_entry(record)
77
+ OutboxEntry.new(
78
+ id: record.id,
79
+ event_id: record.event_id,
80
+ stream_id: record.stream_id,
81
+ version: record.version,
82
+ handler_class: record.handler_class,
83
+ status: record.status.to_sym,
84
+ locked_at: record.locked_at,
85
+ delivered_at: record.delivered_at,
86
+ error: record.error,
87
+ created_at: record.created_at
88
+ )
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ module OutboxStore
5
+ class InMemory
6
+ def initialize(_model = nil)
7
+ @entries = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def insert(event_id:, stream_id:, version:, handler_class:)
12
+ entry = OutboxEntry.new(
13
+ id: SecureRandom.uuid,
14
+ event_id: event_id,
15
+ stream_id: stream_id,
16
+ version: version,
17
+ handler_class: handler_class.name,
18
+ status: :pending,
19
+ locked_at: nil,
20
+ delivered_at: nil,
21
+ error: nil,
22
+ created_at: Time.now
23
+ )
24
+ @mutex.synchronize { @entries[entry.id] = entry }
25
+ entry
26
+ end
27
+
28
+ def all
29
+ @mutex.synchronize { @entries.values.dup }
30
+ end
31
+
32
+ def pending
33
+ @mutex
34
+ .synchronize { @entries.values.select { |e| e.status == :pending }
35
+ .sort_by { |e| [e.stream_id, e.version] } }
36
+ end
37
+
38
+ def lock(entry, locked_at: Time.now)
39
+ updated = entry.with(status: :locked, locked_at: locked_at)
40
+ @mutex.synchronize { @entries[entry.id] = updated }
41
+ updated
42
+ end
43
+
44
+ def mark_delivered(entry, delivered_at: Time.now)
45
+ updated = entry.with(status: :delivered, delivered_at: delivered_at)
46
+ @mutex.synchronize { @entries[entry.id] = updated }
47
+ updated
48
+ end
49
+
50
+ def mark_failed(entry, error:)
51
+ updated = entry.with(status: :failed, error: error.message)
52
+ @mutex.synchronize { @entries[entry.id] = updated }
53
+ updated
54
+ end
55
+
56
+ def recover_stale_locks(older_than:)
57
+ stale = @mutex.synchronize do
58
+ @entries.values.select { |e| e.status == :locked && e.locked_at < older_than }
59
+ end
60
+
61
+ stale.map do |entry|
62
+ updated = entry.with(status: :pending, locked_at: nil)
63
+ @mutex.synchronize { @entries[entry.id] = updated }
64
+ updated
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/tcb/record.rb CHANGED
@@ -2,26 +2,33 @@
2
2
 
3
3
  module TCB
4
4
  class Record
5
- def self.call(events_from:, events:, within:, store:, registrations:, &block)
5
+ def self.call(
6
+ events_from:, events:, within:, store:, registrations:, outbox_registrations: [], &block
7
+ )
6
8
  raise ArgumentError, "events_from: or events: must be provided" if events_from.empty? && events.empty?
7
9
 
8
10
  new(
9
- events_from: events_from,
10
- events: events,
11
- store: store,
12
- registrations: registrations,
13
- correlation_id: Thread.current[:tcb_correlation_id],
14
- causation_id: Thread.current[:tcb_causation_id]
11
+ events_from: events_from,
12
+ events: events,
13
+ store: store,
14
+ registrations: registrations,
15
+ outbox_registrations: outbox_registrations,
16
+ correlation_id: Thread.current[:tcb_correlation_id],
17
+ causation_id: Thread.current[:tcb_causation_id]
15
18
  ).call(within: within, &block)
16
19
  end
17
20
 
18
- def initialize(events_from:, events:, store:, registrations:, correlation_id: nil, causation_id: nil)
19
- @events_from = events_from
20
- @events = events
21
- @store = store
22
- @registrations = registrations
23
- @correlation_id = correlation_id
24
- @causation_id = causation_id
21
+ def initialize(
22
+ events_from:, events:, store:, registrations:, outbox_registrations: [],
23
+ correlation_id: nil, causation_id: nil
24
+ )
25
+ @events_from = events_from
26
+ @events = events
27
+ @store = store
28
+ @registrations = registrations
29
+ @outbox_registrations = outbox_registrations
30
+ @correlation_id = correlation_id
31
+ @causation_id = causation_id
25
32
  end
26
33
 
27
34
  def call(within:, &block)
@@ -38,7 +45,9 @@ module TCB
38
45
  block.call if block
39
46
  events = @events_from.flat_map(&:pull_recorded_events)
40
47
  events += @events
41
- persist(events)
48
+ envelopes = persist(events)
49
+ insert_outbox_entries(envelopes)
50
+ envelopes
42
51
  rescue
43
52
  @events_from.each(&:pull_recorded_events)
44
53
  raise
@@ -53,6 +62,19 @@ module TCB
53
62
  order_by_original(events, persisted, remaining)
54
63
  end
55
64
 
65
+ def insert_outbox_entries(envelopes)
66
+ envelopes.each do |envelope|
67
+ @outbox_registrations
68
+ .select { |r| r.event_class == envelope.event.class }
69
+ .each { |r| r.outbox_store.insert(
70
+ event_id: envelope.event_id,
71
+ stream_id: envelope.stream_id,
72
+ version: envelope.version,
73
+ handler_class: r.handler
74
+ )}
75
+ end
76
+ end
77
+
56
78
  def persist_to_store(events)
57
79
  grouped = Hash.new { |h, k| h[k] = [] }
58
80
 
data/lib/tcb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TCB
4
- VERSION = "0.6.2"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/tcb.rb CHANGED
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tcb/envelope"
4
+ require_relative "tcb/outbox_entry"
4
5
  require_relative "tcb/minitest_helpers"
5
6
  require_relative "tcb/domain_context"
6
7
  require_relative "tcb/correlation_query"
7
8
  require_relative "tcb/event_query"
8
9
  require_relative "tcb/event_store/active_record"
9
10
  require_relative "tcb/event_store/in_memory"
11
+ require_relative "tcb/outbox_store/in_memory"
12
+ require_relative "tcb/outbox_store/active_record"
13
+ require_relative "tcb/outbox_relay"
10
14
  require_relative "tcb/stream_id"
11
15
  require_relative "tcb/handles_events"
12
16
  require_relative "tcb/handles_commands"
@@ -43,11 +47,12 @@ module TCB
43
47
 
44
48
  def self.record(events_from: [], events: [], within: nil, &block)
45
49
  Record.call(
46
- events_from: events_from,
47
- events: events,
48
- within: within,
49
- store: config.event_store,
50
- registrations: config.persist_registrations,
50
+ events_from: events_from,
51
+ events: events,
52
+ within: within,
53
+ store: config.event_store,
54
+ registrations: config.persist_registrations,
55
+ outbox_registrations: config.outbox_registrations,
51
56
  &block
52
57
  )
53
58
  end
@@ -59,6 +64,16 @@ module TCB
59
64
  )
60
65
  end
61
66
 
67
+ def self.reactions_for(event_class)
68
+ domain_modules.flat_map do |mod|
69
+ next [] unless mod.respond_to?(:event_handler_registrations)
70
+
71
+ mod.event_handler_registrations
72
+ .select { |r| r.event_class == event_class }
73
+ .flat_map(&:handlers)
74
+ end
75
+ end
76
+
62
77
  def self.read_correlation(correlation_id, across: nil)
63
78
  domains = across || config.domain_modules.select do |m|
64
79
  m.respond_to?(:persist_registrations) && m.persist_registrations.any?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tcb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ljubomir Marković
@@ -141,6 +141,9 @@ files:
141
141
  - lib/generators/tcb/event_store/templates/migration.rb.tt
142
142
  - lib/generators/tcb/install/install_generator.rb
143
143
  - lib/generators/tcb/install/templates/tcb.rb.tt
144
+ - lib/generators/tcb/outbox/outbox_generator.rb
145
+ - lib/generators/tcb/outbox/templates/job.rb.tt
146
+ - lib/generators/tcb/outbox/templates/migration.rb.tt
144
147
  - lib/generators/tcb/shared/command_argument.rb
145
148
  - lib/tcb.rb
146
149
  - lib/tcb/command_bus.rb
@@ -163,6 +166,10 @@ files:
163
166
  - lib/tcb/handles_commands.rb
164
167
  - lib/tcb/handles_events.rb
165
168
  - lib/tcb/minitest_helpers.rb
169
+ - lib/tcb/outbox_entry.rb
170
+ - lib/tcb/outbox_relay.rb
171
+ - lib/tcb/outbox_store/active_record.rb
172
+ - lib/tcb/outbox_store/in_memory.rb
166
173
  - lib/tcb/publish.rb
167
174
  - lib/tcb/record.rb
168
175
  - lib/tcb/records_events.rb
@@ -192,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
192
199
  - !ruby/object:Gem::Version
193
200
  version: '0'
194
201
  requirements: []
195
- rubygems_version: 4.0.6
202
+ rubygems_version: 4.0.11
196
203
  specification_version: 4
197
204
  summary: Lightweight DDD runtime for Rails — events, commands, and aggregates
198
205
  test_files: []