Class: SolidLoop::LeaseHeartbeat

Inherits:
Object
  • Object
show all
Defined in:
lib/solid_loop/lease_heartbeat.rb

Overview

A thin per-turn HANDLE over the process-wide LeaseRenewer (docs/decisions/durable_attempt_lease.md). It preserves the exact public surface the LLM path relies on (run, start, stop, lost?, check!) so callers (LlmCompletionJob, ResponseParsing) are unchanged.

WHY it no longer owns a thread/connection: the old design spawned ONE renewer thread per in-flight turn and each renew tick checked out its own pool connection. At full worker-pool occupancy (pool size == worker concurrency, the standard Rails shape) every connection is held by a busy worker, so every heartbeat blocked in with_connection and — after checkout_timeout — treated pool exhaustion as a lost lease, yielding healthy turns → the reaper requeued them into the same saturated pool → LIVELOCK. Renewal is now O(1) in connections: the shared LeaseRenewer renews ALL registered leases from ONE dedicated connection, so a per-turn renew never competes with workers again.

This handle registers the turn on start and deregisters on stop. The ownership contract is UNCHANGED and now lives in the renewer: each renew is a conditional UPDATE keyed on loop_id + execution_token, gated by lease_expires_at IS NOT NULL (never resurrects a NULLed tool-phase lease); a genuine 0-row loss (rotated away / reclaimed) flips a per-registration lost flag; the main path checks lost?/check! before any canonical write; stop deregisters BEFORE the canonical commit and again in ensure.

Constant Summary collapse

MIN_INTERVAL =

Kept for source/back-compat; the cadence now lives in the renewer.

SolidLoop::LeaseRenewer::MIN_INTERVAL

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(loop_id:, execution_token:, lease_duration:, max_duration: nil, logger: nil) ⇒ LeaseHeartbeat

Returns a new instance of LeaseHeartbeat.



45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/solid_loop/lease_heartbeat.rb', line 45

def initialize(loop_id:, execution_token:, lease_duration:, max_duration: nil, logger: nil)
  @loop_id         = loop_id
  @execution_token = execution_token
  @lease_duration  = lease_duration.to_f
  # The owning agent's legal turn bound, forwarded to the renewer to size the
  # leak ceiling (max_duration + grace). A legitimate turn — capped at
  # max_duration by the agent's own contract — is therefore never dropped.
  @max_duration    = max_duration
  @logger          = logger || (defined?(Rails) ? Rails.logger : nil)
  @renewer         = SolidLoop::LeaseRenewer.instance
  @key             = nil
end

Instance Attribute Details

#execution_tokenObject (readonly)

Returns the value of attribute execution_token.



43
44
45
# File 'lib/solid_loop/lease_heartbeat.rb', line 43

def execution_token
  @execution_token
end

#lease_durationObject (readonly)

Returns the value of attribute lease_duration.



43
44
45
# File 'lib/solid_loop/lease_heartbeat.rb', line 43

def lease_duration
  @lease_duration
end

#loop_idObject (readonly)

Returns the value of attribute loop_id.



43
44
45
# File 'lib/solid_loop/lease_heartbeat.rb', line 43

def loop_id
  @loop_id
end

#max_durationObject (readonly)

Returns the value of attribute max_duration.



43
44
45
# File 'lib/solid_loop/lease_heartbeat.rb', line 43

def max_duration
  @max_duration
end

Class Method Details

.run(loop_id:, execution_token:, lease_duration:, max_duration: nil, logger: nil) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/solid_loop/lease_heartbeat.rb', line 30

def self.run(loop_id:, execution_token:, lease_duration:, max_duration: nil, logger: nil)
  hb = new(loop_id: loop_id, execution_token: execution_token,
           lease_duration: lease_duration, max_duration: max_duration, logger: logger)
  hb.start
  begin
    yield hb
  ensure
    # Deregister BEFORE the caller's canonical completion so the two never race
    # on lease_expires_at. Idempotent.
    hb.stop
  end
end

Instance Method Details

#check!Object

Raise if the heartbeat has lost the lease. Call this on the main path immediately before any canonical write/checkpoint.



79
80
81
# File 'lib/solid_loop/lease_heartbeat.rb', line 79

def check!
  raise SolidLoop::LostLease, "lease for loop #{@loop_id} lost (generation rotated away)" if lost?
end

#lost?Boolean

True once a conditional renew for THIS turn touched 0 rows (the lease is no longer ours). Thread-safe; readable from the main path between chunks.

Returns:

  • (Boolean)


71
72
73
74
75
# File 'lib/solid_loop/lease_heartbeat.rb', line 71

def lost?
  return false unless @key

  @renewer.lost?(@key)
end

#startObject

Register this turn with the shared renewer. Idempotent.



59
60
61
62
63
64
65
66
67
# File 'lib/solid_loop/lease_heartbeat.rb', line 59

def start
  return self if @key

  @key = @renewer.register(
    loop_id: @loop_id, execution_token: @execution_token,
    lease_duration: @lease_duration, max_duration: @max_duration
  )
  self
end

#stopObject

Idempotent. Deregisters this turn from the shared renewer so no further renew fires for it. The renewer thread and its connection are process-scoped and outlive individual turns by design (that is the whole point of the shared renewer), so stop here does NOT tear down the thread — it only unregisters.



87
88
89
90
91
92
# File 'lib/solid_loop/lease_heartbeat.rb', line 87

def stop
  key  = @key
  @key = nil
  @renewer.deregister(key) if key
  nil
end