tcb 0.6.1 → 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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +232 -38
- data/lib/generators/tcb/install/templates/tcb.rb.tt +22 -14
- data/lib/generators/tcb/outbox/outbox_generator.rb +43 -0
- data/lib/generators/tcb/outbox/templates/job.rb.tt +11 -0
- data/lib/generators/tcb/outbox/templates/migration.rb.tt +20 -0
- data/lib/tcb/configuration.rb +70 -2
- data/lib/tcb/domain_context.rb +8 -0
- data/lib/tcb/event_store/in_memory.rb +9 -0
- data/lib/tcb/handles_events.rb +20 -3
- data/lib/tcb/outbox_entry.rb +14 -0
- data/lib/tcb/outbox_relay.rb +41 -0
- data/lib/tcb/outbox_store/active_record.rb +92 -0
- data/lib/tcb/outbox_store/in_memory.rb +69 -0
- data/lib/tcb/record.rb +37 -15
- data/lib/tcb/version.rb +1 -1
- data/lib/tcb.rb +20 -5
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9fdc75ccfefebb2cb421260d89276f946b42f3d0f9e7ed90438b1d2cce1da8c
|
|
4
|
+
data.tar.gz: d48eee8151fc7ea5b2339f8949fc68bd6f04b61062007aff2f335e2f2f1b367d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b8d0a0de064464890a11e71649dbb587fa032cccfd6d57ebaebfe350303ad725519cf7c8b66e8ec48d9b6092d8082e8167daa9d81d3d1cdc632cb3c50f67107
|
|
7
|
+
data.tar.gz: cccbab25a067c195d236f8e83796099d4eae236061021d3fe9a7547016a79b5a4f1bb22d525d9ce8cd506d5eb14be865c31e0417dbb1cc5ef9c8eb2ed52384d3
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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
|
+
|
|
26
|
+
## [0.6.2] - 2026-05-07
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `tcb:install` generator — `TCB.domain_modules=` moved into `Rails.application.config.to_prepare` block; bare initializer runs before Zeitwerk loads application constants, causing `NameError` when domain modules reference Rails classes such as `ApplicationJob` or `ApplicationRecord`
|
|
31
|
+
|
|
8
32
|
## [0.6.1] - 2026-05-06
|
|
9
33
|
|
|
10
34
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,10 +1,59 @@
|
|
|
1
1
|
# TCB
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Is your codebase using every form of coupling except the one architects actually recommend?
|
|
4
|
+
How much does a feature request cost you now?
|
|
5
|
+
|
|
6
|
+
TCB gives each concern its own place, a clean domain language, and a full record
|
|
7
|
+
of everything that happened, so you can understand and evolve your business logic
|
|
8
|
+
with confidence.
|
|
9
|
+
|
|
10
|
+
Imagine the following scenario. An order is placed. Stock gets reserved. The customer is notified.
|
|
11
|
+
Now imagine each piece in its own domain, reacting independently, easy to test in isolation,
|
|
12
|
+
simple to evolve as your business grows, and the full picture always visible.
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
PlaceOrder = Data.define(:order_id, :customer)
|
|
16
|
+
def Sales.place!(order_id:, customer:) = TCB.dispatch(PlaceOrder.new(order_id:, customer:))
|
|
17
|
+
|
|
18
|
+
correlation_id = Sales.place!(order_id: 42, customer: "Alice")
|
|
19
|
+
# Sales → OrderPlaced persisted, published
|
|
20
|
+
# Warehouse → StockReserved reacts automatically, same correlation
|
|
21
|
+
# Notifications → CustomerNotified reacts automatically, same correlation
|
|
4
22
|
|
|
5
|
-
TCB
|
|
23
|
+
TCB.read_correlation(correlation_id).to_a # => all three events, across all three domains, in order
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A lightweight, thread-safe event and command runtime for Domain-Driven Design on Rails.
|
|
27
|
+
Events, aggregates, and handlers are plain Ruby. No framework inheritance, no infrastructure
|
|
28
|
+
details leaking into your domain.
|
|
29
|
+
|
|
30
|
+
Rails can change. Your domains should change only when your business demands it.
|
|
31
|
+
Clean domain code pays compound interest. It's easier to reason about, easier to test,
|
|
32
|
+
and easier for AI agents to work with. TCB keeps your domain that way.
|
|
33
|
+
Rails takes care of the rest.
|
|
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)
|
|
6
55
|
|
|
7
|
-
|
|
56
|
+
---
|
|
8
57
|
|
|
9
58
|
## Installation
|
|
10
59
|
|
|
@@ -46,11 +95,11 @@ bus.publish(UserRegistered.new(id: 1, email: "alice@example.com"))
|
|
|
46
95
|
|
|
47
96
|
### Execution model
|
|
48
97
|
|
|
49
|
-
TCB::EventBus uses a single background thread to process events. Publishing is non-blocking
|
|
98
|
+
TCB::EventBus uses a single background thread to process events. Publishing is non-blocking. The event is placed on a queue and control returns to the caller immediately. The dispatcher thread processes events in FIFO order. Handlers for a given event execute sequentially within the dispatcher thread.
|
|
50
99
|
|
|
51
100
|
This design favors determinism and simplicity: events are always processed in the order they were published, and handlers cannot race with each other.
|
|
52
101
|
|
|
53
|
-
For tests and simple use cases, `sync: true` executes handlers in the caller thread immediately
|
|
102
|
+
For tests and simple use cases, `sync: true` executes handlers in the caller thread immediately, no background thread, no queue:
|
|
54
103
|
|
|
55
104
|
```ruby
|
|
56
105
|
bus = TCB::EventBus.new(sync: true)
|
|
@@ -77,7 +126,7 @@ The event queue is unbounded by default. If handlers are slower than the rate of
|
|
|
77
126
|
TCB::EventBus.new(max_queue_size: 10_000)
|
|
78
127
|
```
|
|
79
128
|
|
|
80
|
-
When the queue is full, `publish` blocks until space is available. The right value depends on your event volume and handler latency
|
|
129
|
+
When the queue is full, `publish` blocks until space is available. The right value depends on your event volume and handler latency. Measure before deciding.
|
|
81
130
|
|
|
82
131
|
---
|
|
83
132
|
|
|
@@ -137,10 +186,10 @@ module Notifications
|
|
|
137
186
|
end
|
|
138
187
|
```
|
|
139
188
|
|
|
140
|
-
Event classes can come from anywhere. Cross-module reactions are the norm, not the exception. Each handler is isolated.
|
|
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.
|
|
141
190
|
|
|
142
191
|
Domain modules are declared once at the top level, before infrastructure is configured.
|
|
143
|
-
This is the only place TCB needs to know about your bounded contexts
|
|
192
|
+
This is the only place TCB needs to know about your bounded contexts. All reactions,
|
|
144
193
|
persistence rules, and handler mappings live inside each module itself.
|
|
145
194
|
|
|
146
195
|
```ruby
|
|
@@ -154,7 +203,7 @@ end
|
|
|
154
203
|
|
|
155
204
|
`TCB.domain_modules=` wires up subscriptions and command routing from all modules.
|
|
156
205
|
`TCB.configure` provides the infrastructure they run on. The two are intentionally
|
|
157
|
-
separate
|
|
206
|
+
separate. Domain modules don't change between environments, infrastructure does.
|
|
158
207
|
|
|
159
208
|
---
|
|
160
209
|
|
|
@@ -229,32 +278,24 @@ The domain module is a boundary. Everything inside speaks the domain language. N
|
|
|
229
278
|
module Sales
|
|
230
279
|
include TCB::Domain
|
|
231
280
|
|
|
232
|
-
# Facade
|
|
233
281
|
def self.place!(order_id:, customer:)
|
|
234
282
|
TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
|
|
235
283
|
end
|
|
236
284
|
|
|
237
|
-
# Events
|
|
238
285
|
OrderPlaced = Data.define(:order_id, :customer)
|
|
239
286
|
|
|
240
|
-
# Commands
|
|
241
287
|
PlaceOrder = Data.define(:order_id, :customer) do
|
|
242
288
|
def validate!
|
|
243
289
|
raise ArgumentError, "customer required" if customer.nil?
|
|
244
290
|
end
|
|
245
291
|
end
|
|
246
292
|
|
|
247
|
-
# Persistence
|
|
248
293
|
persist events(
|
|
249
294
|
OrderPlaced,
|
|
250
295
|
stream_id_from_event: :order_id
|
|
251
296
|
)
|
|
252
297
|
|
|
253
|
-
# Commands
|
|
254
298
|
handle PlaceOrder, with(PlaceOrderHandler)
|
|
255
|
-
|
|
256
|
-
# Reactions
|
|
257
|
-
on OrderPlaced, react_with(Warehouse::ReserveStock)
|
|
258
299
|
end
|
|
259
300
|
```
|
|
260
301
|
|
|
@@ -366,11 +407,108 @@ end
|
|
|
366
407
|
|
|
367
408
|
---
|
|
368
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
|
+
|
|
369
507
|
## Configuration
|
|
370
508
|
|
|
371
509
|
### Domain modules
|
|
372
510
|
|
|
373
|
-
Domain modules are the bounded contexts of your application. Declare them once, at the top level
|
|
511
|
+
Domain modules are the bounded contexts of your application. Declare them once, at the top level, before infrastructure is configured:
|
|
374
512
|
|
|
375
513
|
```ruby
|
|
376
514
|
# config/initializers/tcb.rb
|
|
@@ -388,11 +526,12 @@ Infrastructure is environment-specific. Configure it in each environment file so
|
|
|
388
526
|
Rails.application.config.to_prepare do
|
|
389
527
|
TCB.reset!
|
|
390
528
|
TCB.configure do |c|
|
|
391
|
-
c.event_bus
|
|
392
|
-
handle_signals: false,
|
|
529
|
+
c.event_bus = TCB::EventBus.new(
|
|
530
|
+
handle_signals: false,
|
|
393
531
|
shutdown_timeout: 10.0
|
|
394
532
|
)
|
|
395
|
-
c.event_store
|
|
533
|
+
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
534
|
+
c.outbox_store_class = TCB::OutboxStore::ActiveRecord # required if any domain uses ensure_reaction
|
|
396
535
|
end
|
|
397
536
|
end
|
|
398
537
|
```
|
|
@@ -404,11 +543,12 @@ end
|
|
|
404
543
|
Rails.application.config.to_prepare do
|
|
405
544
|
TCB.reset!(graceful_shutdown_time: 10.0)
|
|
406
545
|
TCB.configure do |c|
|
|
407
|
-
c.event_bus
|
|
546
|
+
c.event_bus = TCB::EventBus.new(
|
|
408
547
|
handle_signals: true,
|
|
409
548
|
shutdown_timeout: 30.0
|
|
410
549
|
)
|
|
411
|
-
c.event_store
|
|
550
|
+
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
551
|
+
c.outbox_store_class = TCB::OutboxStore::ActiveRecord
|
|
412
552
|
end
|
|
413
553
|
end
|
|
414
554
|
```
|
|
@@ -419,13 +559,14 @@ end
|
|
|
419
559
|
# config/environments/test.rb
|
|
420
560
|
Rails.application.config.after_initialize do
|
|
421
561
|
TCB.configure do |c|
|
|
422
|
-
c.event_bus
|
|
423
|
-
c.event_store
|
|
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
|
|
424
565
|
end
|
|
425
566
|
end
|
|
426
567
|
```
|
|
427
568
|
|
|
428
|
-
`sync: true` executes handlers in the caller thread
|
|
569
|
+
`sync: true` executes handlers in the caller thread. No background thread, no polling. `after_initialize` runs once at boot. Between tests, call `TCB.reset!` to get a fresh bus and store.
|
|
429
570
|
|
|
430
571
|
Each domain module gets its own database table. Domains stay isolated at the persistence level:
|
|
431
572
|
|
|
@@ -437,6 +578,25 @@ Each domain module gets its own database table. Domains stay isolated at the per
|
|
|
437
578
|
|
|
438
579
|
---
|
|
439
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
|
+
|
|
440
600
|
## Reading Events
|
|
441
601
|
|
|
442
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.
|
|
@@ -483,7 +643,7 @@ envelope.causation_id # UUID string, event_id of the triggering event; nil for
|
|
|
483
643
|
|
|
484
644
|
## Correlation and causation tracking
|
|
485
645
|
|
|
486
|
-
Every `TCB.dispatch` generates a `correlation_id` and returns it to the caller. All events produced within that dispatch chain share the same `correlation_id`, regardless of how deep the reactive chain goes. `causation_id` identifies the direct cause
|
|
646
|
+
Every `TCB.dispatch` generates a `correlation_id` and returns it to the caller. All events produced within that dispatch chain share the same `correlation_id`, regardless of how deep the reactive chain goes. `causation_id` identifies the direct cause, the `event_id` of the envelope that triggered the handler.
|
|
487
647
|
|
|
488
648
|
```
|
|
489
649
|
Sales.place!(order_id: 42, customer: "Alice")
|
|
@@ -529,7 +689,23 @@ TCB.read_correlation("req-abc").between(1.hour.ago, Time.now).to_a
|
|
|
529
689
|
|
|
530
690
|
Results are ordered by `occurred_at` across all domains. Each result is a `TCB::Envelope` with `correlation_id` and `causation_id` populated.
|
|
531
691
|
|
|
532
|
-
`across:` defaults to all configured domain modules that have persistence registrations. Domains without persistence
|
|
692
|
+
`across:` defaults to all configured domain modules that have persistence registrations. Domains without persistence, like `Notifications` in the example above, are excluded automatically.
|
|
693
|
+
|
|
694
|
+
---
|
|
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.
|
|
533
709
|
|
|
534
710
|
---
|
|
535
711
|
|
|
@@ -598,17 +774,21 @@ Generates:
|
|
|
598
774
|
| `--skip-migration` | Skip migration (event_store only) |
|
|
599
775
|
| `--no-comments` | Generate without inline guidance comments |
|
|
600
776
|
|
|
601
|
-
After generating, add your module to config/initializers/tcb.rb. Domain modules don't change between environments
|
|
777
|
+
After generating, add your module to config/initializers/tcb.rb. Domain modules don't change between environments. Infrastructure does. Keeping them separate means your bounded contexts are declared once, while the bus and store are configured per environment:
|
|
602
778
|
|
|
603
779
|
```ruby
|
|
604
780
|
# config/initializers/tcb.rb
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
781
|
+
Rails.application.config.to_prepare do
|
|
782
|
+
TCB.domain_modules = [
|
|
783
|
+
Sales,
|
|
784
|
+
Warehouse,
|
|
785
|
+
Notifications
|
|
786
|
+
]
|
|
787
|
+
end
|
|
610
788
|
```
|
|
611
789
|
|
|
790
|
+
`to_prepare` runs after Zeitwerk loads all application constants. Use it instead of a bare initializer. Domain modules typically reference Rails classes (ApplicationJob, ApplicationRecord, etc.) that are not yet available at initializer time.
|
|
791
|
+
|
|
612
792
|
---
|
|
613
793
|
|
|
614
794
|
## Error Handling
|
|
@@ -645,7 +825,7 @@ bus.force_shutdown
|
|
|
645
825
|
|
|
646
826
|
Configure TCB once at boot in `config/environments/test.rb` (see Configuration above). Between tests, call `TCB.reset!` to get a fresh event bus and a clean event store.
|
|
647
827
|
|
|
648
|
-
`TCB.reset!` shuts down the current bus, clears the event store, and clears all subscriptions. The next test starts with a clean slate. Domain modules do not need to be re-declared
|
|
828
|
+
`TCB.reset!` shuts down the current bus, clears the event store, and clears all subscriptions. The next test starts with a clean slate. Domain modules do not need to be re-declared. They are set once at the top level and persist across resets.
|
|
649
829
|
|
|
650
830
|
### Synchronous mode
|
|
651
831
|
|
|
@@ -661,12 +841,20 @@ Rails.application.config.after_initialize do
|
|
|
661
841
|
end
|
|
662
842
|
```
|
|
663
843
|
|
|
844
|
+
`after_initialize` runs once at boot. Tests do not reload Rails, so `to_prepare` is unnecessary. Between tests, TCB.reset! shuts down the current bus and clears the store, but also wipes the configuration. Restore it in your test teardown so every test starts with a clean, fully configured bus.
|
|
845
|
+
|
|
664
846
|
### Minitest
|
|
665
847
|
|
|
666
848
|
```ruby
|
|
667
849
|
class OrdersTest < Minitest::Test
|
|
668
850
|
include TCB::MinitestHelpers
|
|
669
|
-
def teardown
|
|
851
|
+
def teardown
|
|
852
|
+
TCB.reset!
|
|
853
|
+
TCB.configure do |c|
|
|
854
|
+
c.event_bus = TCB::EventBus.new(sync: true)
|
|
855
|
+
c.event_store = TCB::EventStore::InMemory.new
|
|
856
|
+
end
|
|
857
|
+
end
|
|
670
858
|
|
|
671
859
|
def test_placing_order_publishes_event
|
|
672
860
|
assert_published(Orders::OrderPlaced) do
|
|
@@ -687,7 +875,7 @@ assert_published(Orders::OrderPlaced, within: 0.5) { Orders.place!(...) }
|
|
|
687
875
|
|
|
688
876
|
#### poll_assert
|
|
689
877
|
|
|
690
|
-
Only needed when using an async bus. With `TCB::EventBus.new(sync: true)`, handlers execute in the caller thread and results are available immediately
|
|
878
|
+
Only needed when using an async bus. With `TCB::EventBus.new(sync: true)`, handlers execute in the caller thread and results are available immediately. No polling required.
|
|
691
879
|
|
|
692
880
|
```ruby
|
|
693
881
|
poll_assert("reserve inventory called") { CALLED.include?(:reserve_inventory) }
|
|
@@ -699,7 +887,13 @@ poll_assert("payment processed", within: 2.0) { Payment.completed? }
|
|
|
699
887
|
```ruby
|
|
700
888
|
# spec/support/tcb.rb
|
|
701
889
|
RSpec.configure do |config|
|
|
702
|
-
config.after(:each)
|
|
890
|
+
config.after(:each) do
|
|
891
|
+
TCB.reset!
|
|
892
|
+
TCB.configure do |c|
|
|
893
|
+
c.event_bus = TCB::EventBus.new(sync: true)
|
|
894
|
+
c.event_store = TCB::EventStore::InMemory.new
|
|
895
|
+
end
|
|
896
|
+
end
|
|
703
897
|
end
|
|
704
898
|
```
|
|
705
899
|
|
|
@@ -2,19 +2,27 @@
|
|
|
2
2
|
# Add your domain modules here after generating them:
|
|
3
3
|
# rails generate tcb:event_store orders place_order:order_id,customer
|
|
4
4
|
# rails generate tcb:domain notifications send_welcome_email:user_id,email
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# Infrastructure — how events are transported and stored
|
|
11
|
-
# Runs on every Rails reload in development
|
|
5
|
+
#
|
|
6
|
+
# to_prepare runs after Zeitwerk loads all application constants.
|
|
7
|
+
# Domain modules typically reference Rails classes (ApplicationJob, ApplicationRecord, etc.)
|
|
8
|
+
# that are not yet available at initializer time.
|
|
12
9
|
Rails.application.config.to_prepare do
|
|
13
|
-
TCB.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
18
|
-
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
19
|
-
end
|
|
10
|
+
TCB.domain_modules = [
|
|
11
|
+
# Orders,
|
|
12
|
+
# Notifications,
|
|
13
|
+
]
|
|
20
14
|
end
|
|
15
|
+
|
|
16
|
+
# Infrastructure — how events are transported and stored
|
|
17
|
+
# Configure per environment in config/environments/*.rb so differences are explicit.
|
|
18
|
+
# Example for development:
|
|
19
|
+
#
|
|
20
|
+
# Rails.application.config.to_prepare do
|
|
21
|
+
# TCB.reset!
|
|
22
|
+
# TCB.configure do |c|
|
|
23
|
+
# c.event_bus = TCB::EventBus.new(handle_signals: false, shutdown_timeout: 10.0)
|
|
24
|
+
# c.event_store = TCB::EventStore::ActiveRecord.new
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# See README for production and test examples.
|
|
@@ -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,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
|
data/lib/tcb/configuration.rb
CHANGED
|
@@ -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
|
data/lib/tcb/domain_context.rb
CHANGED
|
@@ -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) }
|
data/lib/tcb/handles_events.rb
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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,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(
|
|
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:
|
|
10
|
-
events:
|
|
11
|
-
store:
|
|
12
|
-
registrations:
|
|
13
|
-
|
|
14
|
-
|
|
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(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
@
|
|
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
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:
|
|
47
|
-
events:
|
|
48
|
-
within:
|
|
49
|
-
store:
|
|
50
|
-
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.
|
|
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:
|
|
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: []
|