Skip to content

Commit db97952

Browse files
committed
fix: Resume heartbeats after database errors
1 parent 15d2237 commit db97952

5 files changed

Lines changed: 51 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- Maintain effect-owner heartbeats during long-running handlers, completion, and
66
failure handling, so healthy external I/O cannot trigger abandoned recovery.
7+
Report failed heartbeat updates and retry on the next configured interval
8+
without consuming effect attempts.
79
- Return a stable effect handle from every `emit`. Wrappers must return it;
810
operations relying on an implicit `nil` result should return `nil` explicitly.
911
- Add abandoned effect recovery with `on_recovery`, optional `on_status`, staged

‎docs/effect-recovery.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ handler therefore remains protected beyond the recovery timeout. This requires
124124
an available database connection for the heartbeat, as well as runtime threads
125125
that can continue running.
126126

127+
Failed updates emit `solid_objects.process.heartbeat_failed` and retry at the
128+
configured heartbeat interval without consuming effect attempts. If an outage
129+
lasts beyond the freshness window, recovery can still be permitted; retries do
130+
not cancel external work or extend the configured window.
131+
127132
## Compatibility and installation
128133

129134
Upgrade all effect workers and process cleanup roles before emitting effects

‎lib/solid_objects/process_heartbeat.rb‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ def start
2525
break if wait_for_interval
2626

2727
Record.connection_pool.with_connection { process_registry.heartbeat }
28+
rescue => error
29+
report_failure(error)
2830
end
2931
end
3032
end
@@ -42,6 +44,15 @@ def stop
4244

4345
attr_reader :process_registry, :mutex, :condition
4446

47+
# @rbs (Exception) -> void
48+
def report_failure(error)
49+
payload = { process_id: process_registry.process_record&.id, error_class: error.class.name }
50+
SolidObjects.configuration.logger.warn({ event: "solid_objects.process.heartbeat_failed", **payload })
51+
SolidObjects.instrument(:"process.heartbeat_failed", **payload)
52+
rescue
53+
nil
54+
end
55+
4556
# @rbs () -> bool
4657
def wait_for_interval
4758
mutex.synchronize do

‎sig/generated/lib/solid_objects/process_heartbeat.rbs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ module SolidObjects
2929

3030
attr_reader condition: untyped
3131

32+
# @rbs (Exception) -> void
33+
def report_failure: (Exception) -> void
34+
3235
# @rbs () -> bool
3336
def wait_for_interval: () -> bool
3437
end

‎test/integration/effect_recovery_test.rb‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,36 @@ def checked(effect_id:, outcome:, arguments: nil, result: nil)
132132
worker&.stop
133133
end
134134

135+
test "heartbeat maintenance resumes after a database error" do
136+
SolidObjects.configuration.process_heartbeat_interval = 0.01
137+
registry = SolidObjects::ProcessRegistry.new
138+
registry.register(kind: "effect")
139+
resumed = Queue.new
140+
attempts = 0
141+
registry.define_singleton_method(:heartbeat) do
142+
attempts += 1
143+
if attempts == 1
144+
SolidObjects::Record.connection.select_value("SELECT absent_heartbeat_column FROM #{SolidObjects.table_name(:processes)}")
145+
end
146+
updated = super()
147+
resumed << true if updated
148+
updated
149+
end
150+
events = []
151+
subscriber = ActiveSupport::Notifications.subscribe("solid_objects.process.heartbeat_failed") { |event| events << event.payload }
152+
heartbeat = SolidObjects::ProcessHeartbeat.new(process_registry: registry)
153+
heartbeat.start
154+
Timeout.timeout(2) { resumed.pop }
155+
heartbeat.stop
156+
assert_operator attempts, :>=, 2
157+
assert_equal 1, events.length
158+
assert_equal registry.process_record.id, events.first.fetch(:process_id)
159+
ensure
160+
heartbeat&.stop
161+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
162+
registry&.stop
163+
end
164+
135165
test "a longer recovery timeout survives ordinary process cleanup" do
136166
ExportActor.ref("long-grace").async.start_with_timeout(timeout: 120)
137167
worker = SolidObjects::Worker.new

0 commit comments

Comments
 (0)