feat(v0.4): add heartbeat goroutine that updates lease timestamp

This commit is contained in:
2026-04-21 17:05:15 -04:00
parent ab89a167a5
commit 42fce73824
2 changed files with 154 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
package session
import (
"fmt"
"os"
"sync"
"time"
)
// HeartbeatInterval is the default cadence for updating the lease's
// last_heartbeat_at timestamp.
const HeartbeatInterval = 30 * time.Second
// StaleLeaseAfter is the age at which a lease's heartbeat is considered dead.
const StaleLeaseAfter = 60 * time.Second
// Heartbeat is a background goroutine that periodically rewrites the lease
// file at leasePath with an updated LastHeartbeatAt. Stop blocks until the
// goroutine has exited.
type Heartbeat struct {
stop chan struct{}
done chan struct{}
stopOnce sync.Once
}
// StartHeartbeat launches a heartbeat goroutine using the default logger
// (writes to stderr on update failure). Heartbeat write failures are
// best-effort (spec: "log warning but do not interrupt the session").
func StartHeartbeat(leasePath string, interval time.Duration) *Heartbeat {
return StartHeartbeatWith(leasePath, interval, func(err error) {
fmt.Fprintf(os.Stderr, "[ctask] Warning: heartbeat write failed: %v\n", err)
})
}
// StartHeartbeatWith is the test-injectable form of StartHeartbeat: on each
// update failure, onErr is invoked instead of writing to stderr.
func StartHeartbeatWith(leasePath string, interval time.Duration, onErr func(error)) *Heartbeat {
h := &Heartbeat{
stop: make(chan struct{}),
done: make(chan struct{}),
}
go h.run(leasePath, interval, onErr)
return h
}
func (h *Heartbeat) run(leasePath string, interval time.Duration, onErr func(error)) {
defer close(h.done)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-h.stop:
return
case <-t.C:
if err := updateHeartbeat(leasePath); err != nil {
onErr(err)
}
}
}
}
// Stop signals the heartbeat to exit and waits for the goroutine to finish.
// Safe to call more than once.
func (h *Heartbeat) Stop() {
h.stopOnce.Do(func() {
close(h.stop)
})
<-h.done
}
// updateHeartbeat reads the lease file, overwrites LastHeartbeatAt with the
// current UTC timestamp, and writes the lease back.
func updateHeartbeat(leasePath string) error {
l, err := ReadLease(leasePath)
if err != nil {
return err
}
l.LastHeartbeatAt = time.Now().UTC().Truncate(time.Second)
return WriteLease(leasePath, l)
}