Class: GenevaDrive::Signal
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- GenevaDrive::Signal
- Defined in:
- lib/geneva_drive/signal.rb
Overview
A per-workflow event record. Signals are how the outside world tells a workflow that something happened: a webhook landed, a human clicked "approve", another system finished its part of the job.
A signal is always addressed to one workflow instance and is persisted before any processing happens, so it can never be lost between arriving and being noticed - a signal that arrives before the waiting step even exists simply sits in the table until the step's gate picks it up.
The lifecycle, in the state column, is pending -> claimed -> consumed:
pending: persisted, nobody is working on itclaimed: attached to at least one step execution, at least one of which has not resolved yetconsumed: every attached chain resolved cleanly; no new execution may attach
Alongside the state there are two counters, claimed and consumed: how
many executions have attached to this event, and how many attached chains
have resolved cleanly.
Constant Summary collapse
- ACTIVE_EXECUTION_STATES =
Step execution states that mean "this chain has not come to rest yet".
%w[waiting scheduled in_progress].freeze
- CLEAN_OUTCOMES =
Outcomes that resolve an attached chain cleanly, so the signal can be considered handled by it.
%w[success skipped].freeze
Class Method Summary collapse
-
.reset_table_available_cache! ⇒ void
Clears the cached table detection result.
-
.serialize_payload(value) ⇒ Object?
Serializes a payload using ActiveJob serializers (handles Date, Time, and other types) and enforces GenevaDrive.max_signal_payload_size on the serialized JSON.
-
.table_available? ⇒ Boolean
Lazily checks whether the signals table has been migrated.
Instance Method Summary collapse
-
#claim! ⇒ void
private
Records one execution attaching to this signal, bumping the claimed count.
-
#claimed? ⇒ Boolean
Whether at least one execution has ever attached to this signal.
-
#consumed? ⇒ Boolean
Whether at least one attached chain has resolved cleanly.
-
#duplicate_delivery! ⇒ void
private
Flags this instance as the result of a deduplicated delivery.
-
#duplicate_delivery? ⇒ Boolean
Whether this instance was returned from a deduplicated delivery - that is, Workflow#signal! found an existing row with the same idempotency key instead of inserting a new one.
-
#fully_resolved?(resolved_step_names: []) ⇒ Boolean
private
Whether every execution chain attached to this signal has come to a clean stop.
-
#payload ⇒ Object?
Returns the deserialized payload.
-
#payload=(value) ⇒ void
Sets the payload, serializing it through ActiveJob serializers.
-
#record_consumption!(resolved_step_names: []) ⇒ void
private
Records one attached execution chain resolving cleanly, bumping the consumed count.
-
#step_executions ⇒ ActiveRecord::Relation<GenevaDrive::StepExecution>
Step executions pinned to this signal.
Class Method Details
.reset_table_available_cache! ⇒ void
This method returns an undefined value.
Clears the cached table detection result. Call this in tests or after running migrations in-process so the next access re-checks.
83 84 85 |
# File 'lib/geneva_drive/signal.rb', line 83 def reset_table_available_cache! remove_instance_variable(:@_table_available) if defined?(@_table_available) end |
.serialize_payload(value) ⇒ Object?
Serializes a payload using ActiveJob serializers (handles Date, Time, and other types) and enforces GenevaDrive.max_signal_payload_size on the serialized JSON. The single serialization path for payload writes.
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/geneva_drive/signal.rb', line 94 def serialize_payload(value) return nil if value.nil? serialized = ActiveJob::Arguments.serialize([value]).first limit = GenevaDrive.max_signal_payload_size if limit bytesize = JSON.generate(serialized).bytesize if bytesize > limit raise GenevaDrive::SignalPayloadTooLargeError, "Serialized signal payload is #{bytesize} bytes, exceeding " \ "GenevaDrive.max_signal_payload_size (#{limit} bytes). A payload describes the " \ "event, it is not a place to ship the data the step should be working on - " \ "write what matters onto the hero instead. Set " \ "GenevaDrive.max_signal_payload_size to nil to disable this check." end end serialized end |
.table_available? ⇒ Boolean
Lazily checks whether the signals table has been migrated. Never hits the database at class definition time - only on the first runtime call.
Deployments usually ship the gem update before running migrations, so everything except actually sending or waiting for a signal must keep working when the table is absent.
71 72 73 74 75 76 77 |
# File 'lib/geneva_drive/signal.rb', line 71 def table_available? if defined?(@_table_available) return @_table_available end @_table_available = table_exists? end |
Instance Method Details
#claim! ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Records one execution attaching to this signal, bumping the claimed count. The pending -> claimed state flip and claimed_at happen on the first claim only, so the timestamp keeps meaning "when this event started being handled". A retry attaching to a still-claimed signal counts as a new claim; a successor continuing the same chain does not (it carries the pin over rather than acquiring it).
180 181 182 183 184 185 186 187 |
# File 'lib/geneva_drive/signal.rb', line 180 def claim! attrs = {claimed: claimed + 1} if state == "pending" attrs[:state] = "claimed" attrs[:claimed_at] = Time.current end update!(attrs) end |
#claimed? ⇒ Boolean
Whether at least one execution has ever attached to this signal. Reads the counter, not the lifecycle state, so it stays true after the signal has been consumed - "this event was picked up" rather than "is being handled right now".
162 |
# File 'lib/geneva_drive/signal.rb', line 162 def claimed? = claimed > 0 |
#consumed? ⇒ Boolean
Whether at least one attached chain has resolved cleanly. Reads the counter, so for a one-to-many dispatch it goes true with the first resolved branch, while the state only flips once the last one resolves.
169 |
# File 'lib/geneva_drive/signal.rb', line 169 def consumed? = consumed > 0 |
#duplicate_delivery! ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Flags this instance as the result of a deduplicated delivery.
152 153 154 |
# File 'lib/geneva_drive/signal.rb', line 152 def duplicate_delivery! @duplicate_delivery = true end |
#duplicate_delivery? ⇒ Boolean
Whether this instance was returned from a deduplicated delivery - that is, Workflow#signal! found an existing row with the same idempotency key instead of inserting a new one. Not a column: it is a fact about this call, not about the record.
144 145 146 |
# File 'lib/geneva_drive/signal.rb', line 144 def duplicate_delivery? !!@duplicate_delivery end |
#fully_resolved?(resolved_step_names: []) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Whether every execution chain attached to this signal has come to a clean stop. A chain that is still running (or parked), and one whose latest attached execution failed or was canceled, both keep the state at claimed - the failed chain's retry re-attaches to it and reads the same payload.
Only the most recent attached execution per step counts: the earlier ones
in a chain end with continued or reattempted precisely because they
handed the work to the next one.
224 225 226 227 228 229 230 231 232 233 234 235 |
# File 'lib/geneva_drive/signal.rb', line 224 def fully_resolved?(resolved_step_names: []) settled = Array(resolved_step_names).map(&:to_s) attached = step_executions.order(created_at: :asc, id: :asc).to_a return true if attached.empty? unsettled = attached.reject { |execution| settled.include?(execution.step_name) } return false if unsettled.any? { |execution| ACTIVE_EXECUTION_STATES.include?(execution.state) } unsettled.group_by(&:step_name).all? do |_step_name, chain| CLEAN_OUTCOMES.include?(chain.last.outcome) end end |
#payload ⇒ Object?
Returns the deserialized payload. Hashes come back with indifferent access, because webhook senders produce string keys while Ruby senders produce symbols and matchers should not have to know which.
121 122 123 124 125 126 127 |
# File 'lib/geneva_drive/signal.rb', line 121 def payload raw = self[:payload] return nil if raw.nil? value = ActiveJob::Arguments.deserialize([raw]).first value.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(value) : value end |
#payload=(value) ⇒ void
This method returns an undefined value.
Sets the payload, serializing it through ActiveJob serializers.
134 135 136 |
# File 'lib/geneva_drive/signal.rb', line 134 def payload=(value) self[:payload] = self.class.serialize_payload(value) end |
#record_consumption!(resolved_step_names: []) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Records one attached execution chain resolving cleanly, bumping the consumed count. The state flips to consumed - closing the signal to new attachments - only once every attached chain has resolved, which for a single claimant is the same moment.
199 200 201 202 203 204 205 206 |
# File 'lib/geneva_drive/signal.rb', line 199 def record_consumption!(resolved_step_names: []) attrs = {consumed: consumed + 1} if state == "claimed" && fully_resolved?(resolved_step_names: resolved_step_names) attrs[:state] = "consumed" attrs[:consumed_at] = Time.current end update!(attrs) end |
#step_executions ⇒ ActiveRecord::Relation<GenevaDrive::StepExecution>
Step executions pinned to this signal.
240 241 242 |
# File 'lib/geneva_drive/signal.rb', line 240 def step_executions GenevaDrive::StepExecution.where(signal_id: id) end |