tcb 0.5.0 → 0.6.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.
@@ -1,18 +1,20 @@
1
+ # Domain modules — which bounded contexts exist
2
+ # Add your domain modules here after generating them:
3
+ # rails generate tcb:event_store orders place_order:order_id,customer
4
+ # rails generate tcb:domain notifications send_welcome_email:user_id,email
5
+ TCB.domain_modules = [
6
+ # Orders,
7
+ # Notifications,
8
+ ]
9
+
10
+ # Infrastructure — how events are transported and stored
11
+ # Runs on every Rails reload in development
1
12
  Rails.application.config.to_prepare do
2
13
  TCB.configure do |c|
3
14
  c.event_bus = TCB::EventBus.new(
4
15
  handle_signals: true,
5
16
  shutdown_timeout: 10.0
6
17
  )
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
- ]
18
+ c.event_store = TCB::EventStore::ActiveRecord.new
17
19
  end
18
20
  end
@@ -2,10 +2,14 @@
2
2
  module TCB
3
3
  CommandHandlerNotFound = Class.new(StandardError)
4
4
 
5
- def self.dispatch(command)
5
+ def self.dispatch(command, correlation_id: SecureRandom.uuid)
6
6
  validate!(command)
7
7
  handler = resolve_handler(command)
8
+ Thread.current[:tcb_correlation_id] = correlation_id
8
9
  handler.new.call(command)
10
+ correlation_id
11
+ ensure
12
+ Thread.current[:tcb_correlation_id] = nil
9
13
  end
10
14
 
11
15
  def self.validate!(command)
@@ -62,6 +62,10 @@ module TCB
62
62
  ]
63
63
  end
64
64
 
65
+ def event_bus_configured?
66
+ !!@event_bus
67
+ end
68
+
65
69
  private
66
70
 
67
71
  def flush_domain_modules
@@ -70,8 +74,13 @@ module TCB
70
74
 
71
75
  domain_module.event_handler_registrations.each do |registration|
72
76
  registration.handlers.each do |handler|
73
- event_bus.subscribe(registration.event_class) do |event|
74
- handler.new.call(event)
77
+ event_bus.subscribe(registration.event_class) do |envelope|
78
+ Thread.current[:tcb_correlation_id] = envelope.correlation_id
79
+ Thread.current[:tcb_causation_id] = envelope.event_id
80
+ handler.new.call(envelope.event)
81
+ ensure
82
+ Thread.current[:tcb_correlation_id] = nil
83
+ Thread.current[:tcb_causation_id] = nil
75
84
  end
76
85
  end
77
86
  end
@@ -93,12 +102,13 @@ module TCB
93
102
  @persist_registrations = []
94
103
  @domain_modules.each do |domain_module|
95
104
  next unless domain_module.respond_to?(:persist_registrations)
105
+ next unless domain_module.persist_registrations.any?
96
106
 
97
107
  context = DomainContext.from_module(domain_module).to_s
98
108
  domain_module.persist_registrations.each do |registration|
99
109
  @persist_registrations << registration.with(context: context)
100
110
  end
101
- define_event_record_for(domain_module) if domain_module.persist_registrations.any?
111
+ define_event_record_for(domain_module)
102
112
  end
103
113
  end
104
114
 
@@ -111,8 +121,4 @@ module TCB
111
121
  domain_module.const_set(:EventRecord, klass)
112
122
  end
113
123
  end
114
-
115
- def self.config
116
- @config ||= Configuration.new
117
- end
118
124
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class CorrelationQuery
5
+ def initialize(store:, correlation_id:, domains:, occurred_after: nil, occurred_before: nil)
6
+ @store = store
7
+ @correlation_id = correlation_id
8
+ @domains = domains
9
+ @occurred_after = occurred_after
10
+ @occurred_before = occurred_before
11
+ end
12
+
13
+ def occurred_after(time)
14
+ self.class.new(store: @store, correlation_id: @correlation_id, domains: @domains, occurred_after: time, occurred_before: @occurred_before)
15
+ end
16
+
17
+ def occurred_before(time)
18
+ self.class.new(store: @store, correlation_id: @correlation_id, domains: @domains, occurred_after: @occurred_after, occurred_before: time)
19
+ end
20
+
21
+ def between(from, to)
22
+ occurred_after(from).occurred_before(to)
23
+ end
24
+
25
+ def to_a
26
+ @domains.flat_map do |domain|
27
+ context = DomainContext.from_module(domain).to_s
28
+ @store.read_by_correlation(@correlation_id, context: context, occurred_after: @occurred_after, occurred_before: @occurred_before)
29
+ end.sort_by(&:occurred_at)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module TCB
6
+ Envelope = Data.define(
7
+ :event,
8
+ :event_id,
9
+ :stream_id,
10
+ :version,
11
+ :occurred_at,
12
+ :correlation_id,
13
+ :causation_id
14
+ ) do
15
+ def self.wrap(event, correlation_id: nil, causation_id: nil)
16
+ new(
17
+ event: event,
18
+ event_id: SecureRandom.uuid,
19
+ stream_id: nil,
20
+ version: nil,
21
+ occurred_at: Time.now,
22
+ correlation_id: correlation_id,
23
+ causation_id: causation_id
24
+ )
25
+ end
26
+
27
+ def self.coerce(event_or_envelope)
28
+ event_or_envelope.is_a?(self) ? event_or_envelope : wrap(event_or_envelope)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class EventBus
5
+ class QueuePressureMonitor
6
+ def self.for(max_queue_size:, high_water_mark:)
7
+ return new(high_water_mark:) if max_queue_size && high_water_mark
8
+
9
+ NullQueuePressureMonitor.new
10
+ end
11
+
12
+ def initialize(high_water_mark:)
13
+ @high_water_mark = high_water_mark
14
+ @emitted = false
15
+ end
16
+
17
+ def check?(queue_size)
18
+ if queue_size >= @high_water_mark
19
+ return false if @emitted
20
+ @emitted = true
21
+ true
22
+ else
23
+ @emitted = false
24
+ false
25
+ end
26
+ end
27
+ end
28
+
29
+ class NullQueuePressureMonitor
30
+ def check?(_queue_size)
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,12 +3,31 @@
3
3
  module TCB
4
4
  class EventBus
5
5
  class RunningStrategy
6
- def initialize(event_bus)
6
+ def initialize(event_bus, sync: false)
7
7
  @event_bus = event_bus
8
+ @sync = sync
9
+ end
10
+
11
+ def start
12
+ return if @sync
13
+
14
+ @event_bus.dispatcher = Thread.new do
15
+ loop do
16
+ event = @event_bus.queue.pop
17
+ break if event == :shutdown_sentinel
18
+
19
+ @event_bus.dispatch(event)
20
+ @event_bus.dispatch(build_pressure_event) if @event_bus.high_water_mark_reached?
21
+ end
22
+ end
8
23
  end
9
24
 
10
25
  def publish(event)
11
- @event_bus.queue << event
26
+ if @sync
27
+ @event_bus.dispatch(event)
28
+ else
29
+ @event_bus.queue << event
30
+ end
12
31
  event
13
32
  end
14
33
 
@@ -19,6 +38,17 @@ module TCB
19
38
  def shutdown?
20
39
  false
21
40
  end
41
+
42
+ private
43
+
44
+ def build_pressure_event
45
+ EventBusQueuePressure.new(
46
+ queue_size: @event_bus.queue.size,
47
+ max_queue_size: @event_bus.max_queue_size,
48
+ occupancy: @event_bus.queue.size.to_f / @event_bus.max_queue_size,
49
+ occurred_at: Time.now
50
+ )
51
+ end
22
52
  end
23
53
  end
24
54
  end
@@ -61,12 +61,16 @@ module TCB
61
61
  end
62
62
 
63
63
  def force_terminate
64
+ return unless @event_bus.dispatcher
65
+
64
66
  @event_bus.queue << :shutdown_sentinel
65
67
  @event_bus.dispatcher.kill if @event_bus.dispatcher.alive?
66
68
  @event_bus.dispatcher.join(0.1)
67
69
  end
68
70
 
69
71
  def terminate_dispatcher
72
+ return unless @event_bus.dispatcher
73
+
70
74
  @event_bus.queue << :shutdown_sentinel
71
75
  @event_bus.dispatcher.join(0.5)
72
76
  end
data/lib/tcb/event_bus.rb CHANGED
@@ -1,49 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
- require_relative 'event_bus/running_strategy'
5
- require_relative 'event_bus/shutdown_strategy'
6
- require_relative 'event_bus/termination_signal_handler'
7
- require_relative 'event_bus/subscriber_registry'
3
+ require "set"
4
+ require_relative "event_bus/running_strategy"
5
+ require_relative "event_bus/shutdown_strategy"
6
+ require_relative "event_bus/termination_signal_handler"
7
+ require_relative "event_bus/subscriber_registry"
8
8
 
9
9
  module TCB
10
10
  class EventBus
11
11
  class ShutdownError < StandardError; end
12
12
 
13
- attr_reader :queue, :registry, :mutex,
14
- :active_dispatches, :dispatcher, :events_processed_during_shutdown
13
+ attr_reader :queue, :registry, :mutex, :active_dispatches, :events_processed_during_shutdown, :max_queue_size
14
+ attr_accessor :dispatcher
15
15
 
16
16
  def initialize(
17
17
  handle_signals: false,
18
18
  shutdown_timeout: 30.0,
19
19
  shutdown_signals: [:TERM, :INT],
20
- on_signal: nil
20
+ on_signal: nil,
21
+ max_queue_size: nil,
22
+ high_water_mark: nil,
23
+ sync: false
21
24
  )
22
- @queue = Queue.new
25
+ @sync = sync
26
+ @queue = max_queue_size ? SizedQueue.new(max_queue_size) : Queue.new
27
+ @max_queue_size = max_queue_size
28
+ @pressure_monitor = QueuePressureMonitor.for(max_queue_size:, high_water_mark:)
23
29
  @registry = SubscriberRegistry.new
24
30
  @mutex = Mutex.new
25
31
  @active_dispatches = 0
26
32
  @events_processed_during_shutdown = 0
27
- @execution_strategy = RunningStrategy.new(self)
28
-
29
- @dispatcher = Thread.new do
30
- loop do
31
- event = @queue.pop
32
- break if event == :shutdown_sentinel
33
-
34
- dispatch(event)
35
- end
36
- end
37
-
38
- if handle_signals
39
- @termination_signal_handler = TerminationSignalHandler.new(
40
- event_bus: self,
41
- shutdown_timeout: shutdown_timeout,
42
- signals: shutdown_signals,
43
- on_signal: on_signal
44
- )
45
- @termination_signal_handler.install
46
- end
33
+ @execution_strategy = RunningStrategy.new(self, sync: @sync)
34
+ @execution_strategy.start
35
+ install_signal_handlers(shutdown_timeout:, shutdown_signals:, on_signal:) if handle_signals
47
36
  end
48
37
 
49
38
  # Subscribe to a specific event class
@@ -82,30 +71,33 @@ module TCB
82
71
  end
83
72
 
84
73
  # Public for strategy access
85
- def dispatch(event)
74
+ def dispatch(event_or_envelope)
86
75
  @mutex.synchronize { @active_dispatches += 1 }
76
+ envelope = TCB::Envelope.coerce(event_or_envelope)
87
77
 
88
78
  @events_processed_during_shutdown += 1 if shutdown?
89
- handlers = @registry.handlers_for(event.class)
79
+ handlers = @registry.handlers_for(envelope.event.class)
90
80
 
91
81
  handlers.each do |handler|
92
- execute_handler(handler, event)
82
+ execute_handler(handler, envelope)
93
83
  end
94
84
  ensure
95
85
  @mutex.synchronize { @active_dispatches -= 1 }
96
86
  end
97
87
 
88
+ def high_water_mark_reached? = @pressure_monitor.check?(@queue.size)
89
+
98
90
  private
99
91
 
100
- def execute_handler(handler, event)
101
- handler.call(event)
92
+ def execute_handler(handler, envelope)
93
+ handler.call(envelope)
102
94
  rescue => e
103
- return if event.is_a?(SubscriberInvocationFailed)
95
+ return if envelope.event.is_a?(SubscriberInvocationFailed)
104
96
 
105
97
  failure_event = SubscriberInvocationFailed.build(
106
- handler: handler,
107
- original_event: event,
108
- error: e
98
+ handler: handler,
99
+ original_event: envelope.event,
100
+ error: e
109
101
  )
110
102
 
111
103
  if shutdown?
@@ -114,5 +106,15 @@ module TCB
114
106
  publish(failure_event)
115
107
  end
116
108
  end
109
+
110
+ def install_signal_handlers(shutdown_timeout:, shutdown_signals:, on_signal:)
111
+ @termination_signal_handler = TerminationSignalHandler.new(
112
+ event_bus: self,
113
+ shutdown_timeout: shutdown_timeout,
114
+ signals: shutdown_signals,
115
+ on_signal: on_signal
116
+ )
117
+ @termination_signal_handler.install
118
+ end
117
119
  end
118
120
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ EventBusQueuePressure = Data.define(
5
+ :queue_size, # current number of elements in queue
6
+ :max_queue_size, # maximum queue capacity
7
+ :occupancy, # queue_size / max_queue_size (float)
8
+ :occurred_at # timestamp
9
+ )
10
+ end
@@ -9,7 +9,7 @@ module TCB
9
9
  @mutex = Mutex.new
10
10
  end
11
11
 
12
- def append(stream_id:, events:, occurred_at: Time.now)
12
+ def append(stream_id:, events:, occurred_at: Time.now, correlation_id: nil, causation_id: nil)
13
13
  @mutex.synchronize do
14
14
  next_ver = next_version(stream_id)
15
15
 
@@ -17,20 +17,24 @@ module TCB
17
17
  event_id = SecureRandom.uuid
18
18
 
19
19
  event_record_for(stream_id).create!(
20
- event_id: event_id,
21
- stream_id: stream_id,
22
- version: version,
23
- event_type: event.class.name,
24
- payload: serialize(event),
25
- occurred_at: occurred_at
20
+ event_id: event_id,
21
+ stream_id: stream_id,
22
+ version: version,
23
+ event_type: event.class.name,
24
+ payload: serialize(event),
25
+ occurred_at: occurred_at,
26
+ correlation_id: correlation_id,
27
+ causation_id: causation_id
26
28
  )
27
29
 
28
- EventStreamEnvelope.new(
29
- event: event,
30
- event_id: event_id,
31
- stream_id: stream_id,
32
- version: version,
33
- occurred_at: occurred_at
30
+ TCB::Envelope.new(
31
+ event: event,
32
+ event_id: event_id,
33
+ stream_id: stream_id,
34
+ version: version,
35
+ occurred_at: occurred_at,
36
+ correlation_id: correlation_id,
37
+ causation_id: causation_id
34
38
  )
35
39
  end
36
40
 
@@ -49,12 +53,51 @@ module TCB
49
53
  scope = scope.limit(limit) if limit
50
54
 
51
55
  scope.map do |record|
52
- EventStreamEnvelope.new(
53
- event: deserialize(record.payload),
54
- event_id: record.event_id,
55
- stream_id: record.stream_id,
56
- version: record.version,
57
- occurred_at: record.occurred_at
56
+ TCB::Envelope.new(
57
+ event: deserialize(record.payload),
58
+ event_id: record.event_id,
59
+ stream_id: record.stream_id,
60
+ version: record.version,
61
+ occurred_at: record.occurred_at,
62
+ correlation_id: record.correlation_id,
63
+ causation_id: record.causation_id
64
+ )
65
+ end
66
+ end
67
+
68
+ def read_by_correlation(correlation_id, context:, occurred_after: nil, occurred_before: nil)
69
+ tables = find_tables_for_context(context)
70
+ return [] if tables.empty?
71
+
72
+ union_sql = tables.map do |table|
73
+ conditions = ["correlation_id = ?"]
74
+ conditions << "occurred_at > ?" if occurred_after
75
+ conditions << "occurred_at < ?" if occurred_before
76
+
77
+ "SELECT * FROM #{table} WHERE #{conditions.join(' AND ')}"
78
+ end.join(" UNION ALL ")
79
+
80
+ bindings = tables.flat_map do
81
+ params = [correlation_id]
82
+ params << occurred_after if occurred_after
83
+ params << occurred_before if occurred_before
84
+ params
85
+ end
86
+
87
+ sql = "#{union_sql} ORDER BY occurred_at ASC"
88
+ records = ::ActiveRecord::Base.connection.exec_query(
89
+ ::ActiveRecord::Base.sanitize_sql_array([sql, *bindings])
90
+ )
91
+
92
+ records.map do |record|
93
+ TCB::Envelope.new(
94
+ event: deserialize(record["payload"]),
95
+ event_id: record["event_id"],
96
+ stream_id: record["stream_id"],
97
+ version: record["version"],
98
+ occurred_at: Time.parse(record["occurred_at"].to_s),
99
+ correlation_id: record["correlation_id"],
100
+ causation_id: record["causation_id"]
58
101
  )
59
102
  end
60
103
  end
@@ -88,6 +131,13 @@ module TCB
88
131
  def permitted_classes
89
132
  TCB.config.permitted_serialization_classes
90
133
  end
134
+
135
+ def find_tables_for_context(context)
136
+ ::ActiveRecord::Base.connection.tables.select do |table|
137
+ table.start_with?(context.gsub("/", "__").gsub("::", "__")) &&
138
+ table.end_with?("_events")
139
+ end
140
+ end
91
141
  end
92
142
  end
93
143
  end
@@ -10,15 +10,17 @@ module TCB
10
10
  @mutex = Mutex.new
11
11
  end
12
12
 
13
- def append(stream_id:, events:, occurred_at: Time.now)
13
+ def append(stream_id:, events:, occurred_at: Time.now, correlation_id: nil, causation_id: nil)
14
14
  @mutex.synchronize do
15
15
  envelopes = events.map.with_index(next_version(stream_id)) do |event, version|
16
- EventStreamEnvelope.new(
17
- event: event,
18
- event_id: SecureRandom.uuid,
19
- stream_id: stream_id,
20
- version: version,
21
- occurred_at: occurred_at
16
+ Envelope.new(
17
+ event: event,
18
+ event_id: SecureRandom.uuid,
19
+ stream_id: stream_id,
20
+ version: version,
21
+ occurred_at: occurred_at,
22
+ correlation_id: correlation_id,
23
+ causation_id: causation_id
22
24
  )
23
25
  end
24
26
  @streams[stream_id].concat(envelopes)
@@ -35,6 +37,14 @@ module TCB
35
37
  .then { |e| limit ? e.first(limit) : e }
36
38
  end
37
39
 
40
+ def read_by_correlation(correlation_id, context:, occurred_after: nil, occurred_before: nil)
41
+ @mutex.synchronize { @streams.values.flatten.dup }
42
+ .select { |e| e.stream_id.start_with?(context) }
43
+ .select { |e| e.correlation_id == correlation_id }
44
+ .then { |e| occurred_after ? e.select { |env| env.occurred_at > occurred_after } : e }
45
+ .then { |e| occurred_before ? e.select { |env| env.occurred_at < occurred_before } : e }
46
+ end
47
+
38
48
  def reset!
39
49
  @mutex.synchronize do
40
50
  @streams = Hash.new { |h, k| h[k] = [] }
data/lib/tcb/publish.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  module TCB
2
- def self.publish(*events)
3
- events.each { |event| config.event_bus.publish(event) }
4
- events
2
+ def self.publish(*events_or_envelopes)
3
+ events_or_envelopes.each do |e|
4
+ envelope = TCB::Envelope.coerce(e)
5
+ config.event_bus.publish(envelope)
6
+ end
7
+ events_or_envelopes
5
8
  end
6
- end
9
+ end
data/lib/tcb/record.rb CHANGED
@@ -4,19 +4,28 @@ module TCB
4
4
  class Record
5
5
  def self.call(events_from:, events:, within:, store:, registrations:, &block)
6
6
  raise ArgumentError, "events_from: or events: must be provided" if events_from.empty? && events.empty?
7
- new(events_from: events_from, events: events, store: store, registrations: registrations)
8
- .call(within: within, &block)
7
+
8
+ 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]
15
+ ).call(within: within, &block)
9
16
  end
10
17
 
11
- def initialize(events_from:, events:, store:, registrations:)
12
- @events_from = events_from
13
- @events = events
14
- @store = store
15
- @registrations = registrations
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
16
25
  end
17
26
 
18
27
  def call(within:, &block)
19
- if within
28
+ if within.respond_to?(:transaction)
20
29
  within.transaction { execute(&block) }
21
30
  else
22
31
  execute(&block)
@@ -30,15 +39,21 @@ module TCB
30
39
  events = @events_from.flat_map(&:pull_recorded_events)
31
40
  events += @events
32
41
  persist(events)
33
- events
34
42
  rescue
35
43
  @events_from.each(&:pull_recorded_events)
36
44
  raise
37
45
  end
38
46
 
39
47
  def persist(events)
40
- return unless @store
48
+ return events.map { |event| wrap(event) } unless @store
49
+
50
+ persisted = persist_to_store(events)
51
+ remaining = wrap_remaining(events, persisted)
41
52
 
53
+ order_by_original(events, persisted, remaining)
54
+ end
55
+
56
+ def persist_to_store(events)
42
57
  grouped = Hash.new { |h, k| h[k] = [] }
43
58
 
44
59
  events.each do |event|
@@ -49,7 +64,28 @@ module TCB
49
64
  grouped[stream_id.to_s] << event
50
65
  end
51
66
 
52
- grouped.each { |stream_id, grouped_events| @store.append(stream_id: stream_id, events: grouped_events) }
67
+ grouped.flat_map do |stream_id, grouped_events|
68
+ @store.append(
69
+ stream_id: stream_id,
70
+ events: grouped_events,
71
+ correlation_id: @correlation_id,
72
+ causation_id: @causation_id
73
+ )
74
+ end
75
+ end
76
+
77
+ def wrap_remaining(events, persisted)
78
+ persisted_events = persisted.map(&:event)
79
+ events
80
+ .reject { |event| persisted_events.include?(event) }
81
+ .map { |event| wrap(event) }
82
+ end
83
+
84
+ def order_by_original(events, persisted, remaining)
85
+ all = persisted + remaining
86
+ events.map { |event| all.find { |e| e.event == event } }
53
87
  end
88
+
89
+ def wrap(event) = TCB::Envelope.wrap(event, correlation_id: @correlation_id, causation_id: @causation_id)
54
90
  end
55
91
  end
@@ -15,8 +15,8 @@ module TCB
15
15
  def with_subscriptions(*event_classes)
16
16
  captured = Hash.new { |h, k| h[k] = [] }
17
17
  subscriptions = event_classes.map do |event_class|
18
- TCB.config.event_bus.subscribe(event_class) do |event|
19
- captured[event_class] << event
18
+ TCB.config.event_bus.subscribe(event_class) do |envelope|
19
+ captured[event_class] << (envelope.is_a?(TCB::Envelope) ? envelope.event : envelope)
20
20
  end
21
21
  end
22
22
  yield captured