tcb 0.5.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +600 -0
- data/lib/generators/tcb/domain/domain_generator.rb +49 -0
- data/lib/generators/tcb/domain/templates/command_handler.rb.tt +11 -0
- data/lib/generators/tcb/domain/templates/domain_module.rb.tt +34 -0
- data/lib/generators/tcb/event_store/event_store_generator.rb +64 -0
- data/lib/generators/tcb/event_store/templates/command_handler.rb.tt +18 -0
- data/lib/generators/tcb/event_store/templates/domain_module.rb.tt +44 -0
- data/lib/generators/tcb/event_store/templates/migration.rb.tt +14 -0
- data/lib/generators/tcb/install/install_generator.rb +16 -0
- data/lib/generators/tcb/install/templates/tcb.rb.tt +18 -0
- data/lib/generators/tcb/shared/command_argument.rb +39 -0
- data/lib/tcb/command_bus.rb +26 -0
- data/lib/tcb/configuration.rb +118 -0
- data/lib/tcb/domain.rb +8 -0
- data/lib/tcb/domain_context.rb +29 -0
- data/lib/tcb/event_bus/running_strategy.rb +24 -0
- data/lib/tcb/event_bus/shutdown_strategy.rb +88 -0
- data/lib/tcb/event_bus/subscriber_registry.rb +46 -0
- data/lib/tcb/event_bus/termination_signal_handler.rb +55 -0
- data/lib/tcb/event_bus.rb +118 -0
- data/lib/tcb/event_bus_shutdown.rb +11 -0
- data/lib/tcb/event_query.rb +107 -0
- data/lib/tcb/event_store/active_record.rb +93 -0
- data/lib/tcb/event_store/event_stream_envelope.rb +13 -0
- data/lib/tcb/event_store/in_memory.rb +51 -0
- data/lib/tcb/handles_commands.rb +31 -0
- data/lib/tcb/handles_events.rb +44 -0
- data/lib/tcb/minitest_helpers.rb +37 -0
- data/lib/tcb/publish.rb +6 -0
- data/lib/tcb/record.rb +55 -0
- data/lib/tcb/records_events.rb +23 -0
- data/lib/tcb/rspec_helpers.rb +61 -0
- data/lib/tcb/stream_id.rb +33 -0
- data/lib/tcb/subscriber_invocation_failed.rb +31 -0
- data/lib/tcb/subscriber_metadata_extractor.rb +66 -0
- data/lib/tcb/test_helpers/shared.rb +29 -0
- data/lib/tcb/version.rb +5 -0
- data/lib/tcb.rb +57 -0
- metadata +195 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module <%= module_class_name %>
|
|
2
|
+
class <%= current_command.handler_class_name %>
|
|
3
|
+
def call(command)
|
|
4
|
+
<% if comments? %>
|
|
5
|
+
# Perform the side effect and publish an event when done.
|
|
6
|
+
# WelcomeMailer.with(email: command.email).deliver_later
|
|
7
|
+
# TCB.publish(WelcomeEmailSent.new(user_id: command.user_id))
|
|
8
|
+
<% end %>
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module <%= module_class_name %>
|
|
2
|
+
include TCB::Domain
|
|
3
|
+
<% if comments? %>
|
|
4
|
+
|
|
5
|
+
# Events — past tense, immutable facts from other domains or this module
|
|
6
|
+
# UserRegistered = Data.define(:user_id, :email)
|
|
7
|
+
<% end %>
|
|
8
|
+
<% parsed_commands.each do |cmd| %>
|
|
9
|
+
|
|
10
|
+
<%= cmd.command_class_name %> = Data.define(<%= cmd.attrs.map { |a| ":#{a}" }.join(", ") %>) do
|
|
11
|
+
def validate!
|
|
12
|
+
<% cmd.attrs.each do |attr| %>
|
|
13
|
+
raise ArgumentError, "<%= attr %> is required" if <%= attr %>.nil?
|
|
14
|
+
<% end %>
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<% parsed_commands.each do |cmd| %>
|
|
20
|
+
handle <%= cmd.command_class_name %>, with(<%= cmd.handler_class_name %>)
|
|
21
|
+
<% end %>
|
|
22
|
+
<% if comments? %>
|
|
23
|
+
|
|
24
|
+
# Reactions — handlers are called asynchronously when events are published
|
|
25
|
+
# on EventClass, react_with(EventHandlerClass)
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
# Facade
|
|
29
|
+
<% parsed_commands.each do |cmd| %>
|
|
30
|
+
def self.<%= cmd.name %>(<%= cmd.attrs.map { |a| "#{a}:" }.join(", ") %>)
|
|
31
|
+
TCB.publish(<%= cmd.command_class_name %>.new(<%= cmd.attrs.map { |a| "#{a}: #{a}" }.join(", ") %>))
|
|
32
|
+
end
|
|
33
|
+
<% end %>
|
|
34
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../shared/command_argument"
|
|
4
|
+
|
|
5
|
+
module TCB
|
|
6
|
+
module Generators
|
|
7
|
+
class EventStoreGenerator < Rails::Generators::Base
|
|
8
|
+
namespace "TCB:event_store"
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
argument :module_name, type: :string
|
|
12
|
+
argument :commands, type: :array, default: [], banner: "command:attr1,attr2"
|
|
13
|
+
|
|
14
|
+
class_option :skip_domain, type: :boolean, default: false, desc: "Skip domain module generation"
|
|
15
|
+
class_option :skip_migration, type: :boolean, default: false, desc: "Skip migration generation"
|
|
16
|
+
class_option :no_comments, type: :boolean, default: false, desc: "Generate without inline comments"
|
|
17
|
+
|
|
18
|
+
def create_domain_module
|
|
19
|
+
return if options[:skip_domain]
|
|
20
|
+
template "domain_module.rb.tt", "app/domain/#{module_name.underscore}.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_handlers
|
|
24
|
+
return if options[:skip_domain]
|
|
25
|
+
parsed_commands.each do |cmd|
|
|
26
|
+
@current_command = cmd
|
|
27
|
+
template "command_handler.rb.tt", cmd.handler_file_path(module_name.underscore)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_migration
|
|
32
|
+
return if options[:skip_migration]
|
|
33
|
+
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
34
|
+
template "migration.rb.tt", "db/migrate/#{timestamp}_create_#{table_name}.rb"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parsed_commands
|
|
40
|
+
@parsed_commands ||= CommandArgumentParser.parse(commands)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def module_class_name
|
|
44
|
+
module_name.camelize
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def table_name
|
|
48
|
+
"#{module_name.underscore}_events"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def migration_class_name
|
|
52
|
+
"Create#{module_name.camelize}Events"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def comments?
|
|
56
|
+
!options[:no_comments]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def current_command
|
|
60
|
+
@current_command
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module <%= module_class_name %>
|
|
2
|
+
class <%= current_command.handler_class_name %>
|
|
3
|
+
def call(command)
|
|
4
|
+
<% if comments? %>
|
|
5
|
+
# 1. Load or build your aggregate
|
|
6
|
+
# order = Order.new(id: command.order_id)
|
|
7
|
+
|
|
8
|
+
# 2. TCB.record collects events and persists them within a transaction
|
|
9
|
+
# events = TCB.record(aggregates: [order], within: ApplicationRecord) do
|
|
10
|
+
# order.place(...)
|
|
11
|
+
# end
|
|
12
|
+
|
|
13
|
+
# 3. TCB.publish dispatches events to subscribers
|
|
14
|
+
# TCB.publish(*events)
|
|
15
|
+
<% end %>
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module <%= module_class_name %>
|
|
2
|
+
include TCB::Domain
|
|
3
|
+
<% if comments? %>
|
|
4
|
+
|
|
5
|
+
# Events — past tense, immutable facts
|
|
6
|
+
# OrderPlaced = Data.define(:order_id)
|
|
7
|
+
<% end %>
|
|
8
|
+
<% parsed_commands.each do |cmd| %>
|
|
9
|
+
|
|
10
|
+
<%= cmd.command_class_name %> = Data.define(<%= cmd.attrs.map { |a| ":#{a}" }.join(", ") %>) do
|
|
11
|
+
def validate!
|
|
12
|
+
<% cmd.attrs.each do |attr| %>
|
|
13
|
+
raise ArgumentError, "<%= attr %> is required" if <%= attr %>.nil?
|
|
14
|
+
<% end %>
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if comments? %>
|
|
19
|
+
|
|
20
|
+
# Persistence — declare which events to store, and how to derive the stream id.
|
|
21
|
+
# Stream id is derived from an attribute on the event (stream_id_from_event:).
|
|
22
|
+
# Events are persisted by TCB.record in your command handlers (see below).
|
|
23
|
+
# persist events(
|
|
24
|
+
# EventClass,
|
|
25
|
+
# stream_id_from_event: :events_id_attribute
|
|
26
|
+
# )
|
|
27
|
+
<% end %>
|
|
28
|
+
|
|
29
|
+
<% parsed_commands.each do |cmd| %>
|
|
30
|
+
handle <%= cmd.command_class_name %>, with(<%= cmd.handler_class_name %>)
|
|
31
|
+
<% end %>
|
|
32
|
+
<% if comments? %>
|
|
33
|
+
|
|
34
|
+
# Reactions — handlers are called asynchronously when events are published
|
|
35
|
+
# on EventClass, react_with(EventHandlerClass)
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
# Facade
|
|
39
|
+
<% parsed_commands.each do |cmd| %>
|
|
40
|
+
def self.<%= cmd.name %>(<%= cmd.attrs.map { |a| "#{a}:" }.join(", ") %>)
|
|
41
|
+
TCB.dispatch(<%= cmd.command_class_name %>.new(<%= cmd.attrs.map { |a| "#{a}: #{a}" }.join(", ") %>))
|
|
42
|
+
end
|
|
43
|
+
<% end %>
|
|
44
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :<%= table_name %> do |t|
|
|
4
|
+
t.string :event_id, null: false
|
|
5
|
+
t.string :stream_id, null: false
|
|
6
|
+
t.integer :version, null: false
|
|
7
|
+
t.string :event_type, null: false
|
|
8
|
+
t.text :payload, null: false
|
|
9
|
+
t.datetime :occurred_at, null: false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :<%= table_name %>, [:stream_id, :version], unique: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
namespace "TCB:install"
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
desc "Creates a TCB initializer in config/initializers"
|
|
10
|
+
|
|
11
|
+
def create_initializer
|
|
12
|
+
template "tcb.rb.tt", "config/initializers/tcb.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Rails.application.config.to_prepare do
|
|
2
|
+
TCB.configure do |c|
|
|
3
|
+
c.event_bus = TCB::EventBus.new(
|
|
4
|
+
handle_signals: true,
|
|
5
|
+
shutdown_timeout: 10.0
|
|
6
|
+
)
|
|
7
|
+
c.event_store = Rails.env.test? ? TCB::EventStore::InMemory.new
|
|
8
|
+
: TCB::EventStore::ActiveRecord.new
|
|
9
|
+
|
|
10
|
+
# Add your domain modules here after generating them:
|
|
11
|
+
# rails generate tcb:event_store orders place_order:order_id,customer
|
|
12
|
+
# rails generate tcb:domain notifications send_welcome_email:user_id,email
|
|
13
|
+
c.domain_modules = [
|
|
14
|
+
# Orders,
|
|
15
|
+
# Notifications
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
module Generators
|
|
5
|
+
CommandArgument = Data.define(:name, :attrs) do
|
|
6
|
+
def command_class_name
|
|
7
|
+
camelize(name)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def handler_class_name
|
|
11
|
+
"#{camelize(name)}Handler"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def handler_file_name
|
|
15
|
+
"#{name}_handler"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def handler_file_path(module_name)
|
|
19
|
+
"app/domain/#{module_name}/#{handler_file_name}.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def camelize(str)
|
|
25
|
+
str.split("_").map(&:capitalize).join
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class CommandArgumentParser
|
|
30
|
+
def self.parse(args)
|
|
31
|
+
args.map do |arg|
|
|
32
|
+
name, attrs_str = arg.split(":", 2)
|
|
33
|
+
attrs = attrs_str ? attrs_str.split(",").map(&:to_sym) : []
|
|
34
|
+
CommandArgument.new(name: name, attrs: attrs)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
module TCB
|
|
3
|
+
CommandHandlerNotFound = Class.new(StandardError)
|
|
4
|
+
|
|
5
|
+
def self.dispatch(command)
|
|
6
|
+
validate!(command)
|
|
7
|
+
handler = resolve_handler(command)
|
|
8
|
+
handler.new.call(command)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.validate!(command)
|
|
12
|
+
unless command.respond_to?(:validate!)
|
|
13
|
+
raise NotImplementedError, "#{command.class} must implement validate!"
|
|
14
|
+
end
|
|
15
|
+
command.validate!
|
|
16
|
+
end
|
|
17
|
+
private_class_method :validate!
|
|
18
|
+
|
|
19
|
+
def self.resolve_handler(command)
|
|
20
|
+
handler = config.command_handler(command.class)
|
|
21
|
+
raise CommandHandlerNotFound, "No handler registered for #{command.class.name}" unless handler
|
|
22
|
+
|
|
23
|
+
handler
|
|
24
|
+
end
|
|
25
|
+
private_class_method :resolve_handler
|
|
26
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module TCB
|
|
2
|
+
ConfigurationError = Class.new(StandardError)
|
|
3
|
+
|
|
4
|
+
class Configuration
|
|
5
|
+
def initialize
|
|
6
|
+
@persist_registrations = []
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def persist_registrations
|
|
10
|
+
@persist_registrations
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def event_bus=(bus)
|
|
14
|
+
@event_bus = bus
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def event_bus
|
|
18
|
+
@event_bus ||
|
|
19
|
+
raise(
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
"TCB event_bus is not configured. Call TCB.configure { |c| c.event_bus = TCB::EventBus.new }"
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def event_store=(store)
|
|
26
|
+
@event_store = store
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def event_store
|
|
30
|
+
@event_store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def domain_modules=(modules)
|
|
34
|
+
@domain_modules = modules
|
|
35
|
+
flush_domain_modules
|
|
36
|
+
flush_command_handlers
|
|
37
|
+
flush_persist_registrations
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def domain_modules
|
|
41
|
+
@domain_modules || []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def command_handler(command_class)
|
|
45
|
+
@command_handlers ||= {}
|
|
46
|
+
@command_handlers[command_class]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extra_serialization_classes=(classes)
|
|
50
|
+
@extra_serialization_classes = classes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extra_serialization_classes
|
|
54
|
+
@extra_serialization_classes || []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def permitted_serialization_classes
|
|
58
|
+
@permitted_serialization_classes ||= [
|
|
59
|
+
Symbol, Time, Date, BigDecimal,
|
|
60
|
+
*persist_registrations.flat_map(&:event_classes),
|
|
61
|
+
*extra_serialization_classes
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def flush_domain_modules
|
|
68
|
+
@domain_modules.each do |domain_module|
|
|
69
|
+
next unless domain_module.respond_to?(:event_handler_registrations)
|
|
70
|
+
|
|
71
|
+
domain_module.event_handler_registrations.each do |registration|
|
|
72
|
+
registration.handlers.each do |handler|
|
|
73
|
+
event_bus.subscribe(registration.event_class) do |event|
|
|
74
|
+
handler.new.call(event)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def flush_command_handlers
|
|
82
|
+
@command_handlers = {}
|
|
83
|
+
@domain_modules.each do |domain_module|
|
|
84
|
+
next unless domain_module.respond_to?(:command_handler_registrations)
|
|
85
|
+
|
|
86
|
+
domain_module.command_handler_registrations.each do |reg|
|
|
87
|
+
@command_handlers[reg.command_class] = reg.handler
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def flush_persist_registrations
|
|
93
|
+
@persist_registrations = []
|
|
94
|
+
@domain_modules.each do |domain_module|
|
|
95
|
+
next unless domain_module.respond_to?(:persist_registrations)
|
|
96
|
+
|
|
97
|
+
context = DomainContext.from_module(domain_module).to_s
|
|
98
|
+
domain_module.persist_registrations.each do |registration|
|
|
99
|
+
@persist_registrations << registration.with(context: context)
|
|
100
|
+
end
|
|
101
|
+
define_event_record_for(domain_module) if domain_module.persist_registrations.any?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def define_event_record_for(domain_module)
|
|
106
|
+
return if domain_module.const_defined?(:EventRecord, false)
|
|
107
|
+
|
|
108
|
+
klass = Class.new(::ActiveRecord::Base) do
|
|
109
|
+
self.table_name = DomainContext.from_module(domain_module).table_name
|
|
110
|
+
end
|
|
111
|
+
domain_module.const_set(:EventRecord, klass)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.config
|
|
116
|
+
@config ||= Configuration.new
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/tcb/domain.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class DomainContext < Data.define(:value)
|
|
5
|
+
NAMESPACE_SEPARATOR = "/"
|
|
6
|
+
TABLE_SEPARATOR = "__"
|
|
7
|
+
TABLE_SUFFIX = "_events"
|
|
8
|
+
|
|
9
|
+
def self.from_module(domain_module)
|
|
10
|
+
value = domain_module.name
|
|
11
|
+
.gsub("::", NAMESPACE_SEPARATOR)
|
|
12
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
13
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
14
|
+
.downcase
|
|
15
|
+
|
|
16
|
+
new(value: value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def table_name
|
|
24
|
+
value
|
|
25
|
+
.gsub(NAMESPACE_SEPARATOR, TABLE_SEPARATOR)
|
|
26
|
+
.concat(TABLE_SUFFIX)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class EventBus
|
|
5
|
+
class RunningStrategy
|
|
6
|
+
def initialize(event_bus)
|
|
7
|
+
@event_bus = event_bus
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def publish(event)
|
|
11
|
+
@event_bus.queue << event
|
|
12
|
+
event
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def subscribe(event_class, &block)
|
|
16
|
+
@event_bus.registry.add(event_class, block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def shutdown?
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class EventBus
|
|
5
|
+
class ShutdownStrategy
|
|
6
|
+
def initialize(event_bus:, drain:, timeout:)
|
|
7
|
+
@event_bus = event_bus
|
|
8
|
+
@drain = drain
|
|
9
|
+
@timeout = timeout
|
|
10
|
+
@start_time = Time.now
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def publish(event)
|
|
14
|
+
raise ShutdownError, "Cannot publish events after shutdown"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def subscribe(event_class, &block)
|
|
18
|
+
raise ShutdownError, "Cannot subscribe after shutdown"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def shutdown?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def execute
|
|
26
|
+
emit_shutdown_event(:initiated)
|
|
27
|
+
|
|
28
|
+
if @drain
|
|
29
|
+
drain_with_timeout
|
|
30
|
+
else
|
|
31
|
+
force_terminate
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def drain_with_timeout
|
|
38
|
+
deadline = @start_time + @timeout
|
|
39
|
+
|
|
40
|
+
# Wait for queue to drain AND all active dispatches to complete
|
|
41
|
+
loop do
|
|
42
|
+
queue_empty = @event_bus.queue.size == 0
|
|
43
|
+
active_work = @event_bus.mutex.synchronize { @event_bus.active_dispatches }
|
|
44
|
+
|
|
45
|
+
if queue_empty && active_work == 0
|
|
46
|
+
# All work complete
|
|
47
|
+
terminate_dispatcher
|
|
48
|
+
emit_shutdown_event(:completed)
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if Time.now >= deadline
|
|
53
|
+
# Timeout exceeded, force shutdown
|
|
54
|
+
force_terminate
|
|
55
|
+
emit_shutdown_event(:timeout_exceeded)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sleep 0.01 # Small poll interval
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def force_terminate
|
|
64
|
+
@event_bus.queue << :shutdown_sentinel
|
|
65
|
+
@event_bus.dispatcher.kill if @event_bus.dispatcher.alive?
|
|
66
|
+
@event_bus.dispatcher.join(0.1)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def terminate_dispatcher
|
|
70
|
+
@event_bus.queue << :shutdown_sentinel
|
|
71
|
+
@event_bus.dispatcher.join(0.5)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def emit_shutdown_event(status)
|
|
75
|
+
shutdown_event = EventBusShutdown.new(
|
|
76
|
+
status: status,
|
|
77
|
+
drain_requested: @drain,
|
|
78
|
+
timeout_seconds: @timeout,
|
|
79
|
+
events_drained: @event_bus.events_processed_during_shutdown,
|
|
80
|
+
occurred_at: Time.now
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Dispatch directly, bypassing queue
|
|
84
|
+
@event_bus.dispatch(shutdown_event)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class EventBus
|
|
5
|
+
class SubscriberRegistry
|
|
6
|
+
Subscription = Data.define(:event_class, :handler)
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@subscribers = Hash.new { |h, k| h[k] = Set.new }
|
|
10
|
+
@metadata = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(event_class, handler)
|
|
15
|
+
subscription = Subscription.new(event_class: event_class, handler: handler)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
@subscribers[event_class].add(handler)
|
|
18
|
+
@metadata[handler.object_id] = SubscriberMetadataExtractor.new(handler).extract
|
|
19
|
+
end
|
|
20
|
+
subscription
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def remove(subscription)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
@subscribers[subscription.event_class].delete(subscription.handler)
|
|
26
|
+
@metadata.delete(subscription.handler.object_id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handlers_for(event_class)
|
|
31
|
+
@mutex.synchronize { @subscribers[event_class].dup.freeze }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def metadata_for(subscription)
|
|
35
|
+
@mutex.synchronize { @metadata[subscription.handler.object_id] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@subscribers.clear
|
|
41
|
+
@metadata.clear
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class EventBus
|
|
5
|
+
class TerminationSignalHandler
|
|
6
|
+
def initialize(event_bus:, shutdown_timeout:, signals:, on_signal:)
|
|
7
|
+
@event_bus = event_bus
|
|
8
|
+
@shutdown_timeout = shutdown_timeout
|
|
9
|
+
@signals = signals
|
|
10
|
+
@on_signal = on_signal
|
|
11
|
+
@shutdown_thread = nil
|
|
12
|
+
@original_handlers = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def install
|
|
16
|
+
@signals.each do |sig|
|
|
17
|
+
@original_handlers[sig] = Signal.trap(sig) { handle_signal(sig) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def handle_signal(sig)
|
|
24
|
+
if shutdown_in_progress?
|
|
25
|
+
handle_force_shutdown(sig)
|
|
26
|
+
else
|
|
27
|
+
handle_graceful_shutdown(sig)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def shutdown_in_progress?
|
|
32
|
+
@shutdown_thread&.alive?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def handle_graceful_shutdown(sig)
|
|
36
|
+
@shutdown_thread = Thread.new do
|
|
37
|
+
@on_signal&.call(sig) if @on_signal
|
|
38
|
+
@event_bus.shutdown(drain: true, timeout: @shutdown_timeout)
|
|
39
|
+
restore_and_reraise(sig)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def handle_force_shutdown(sig)
|
|
44
|
+
@shutdown_thread.kill
|
|
45
|
+
@event_bus.force_shutdown
|
|
46
|
+
restore_and_reraise(sig)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def restore_and_reraise(sig)
|
|
50
|
+
Signal.trap(sig, @original_handlers[sig])
|
|
51
|
+
Process.kill(sig, Process.pid)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|