Add ZMQ hashblock subscriber for sub-second chain refresh

New internal/zmqmon package subscribes to bitcoind's hashblock
ZMQ topic and emits TipEvents. Uses github.com/go-zeromq/zmq4
(pure Go, builds with CGO_ENABLED=0). Exponential backoff on
connection failure so bitcoind restarts don't kill the
subscriber permanently; event channel drops rather than blocks
if the consumer is slow (signals are advisory, not logs).

Aggregator.Run now takes a <-chan TipEvent; when a tip arrives
it fires an immediate refresh() outside the normal ticker
cadence. With a 5s poll interval and 0.5-1s ZMQ latency from
bitcoind, dashboards now reflect new tips roughly 4x faster.

Endpoint comes from BITCOIN_ZMQ_BLOCK — empty disables ZMQ
entirely and the aggregator just runs on the ticker alone.
This commit is contained in:
satoshi
2026-04-14 11:14:52 +03:00
parent 01829746ac
commit b786f489a1
5 changed files with 156 additions and 5 deletions
+11 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/kamadopool/kamado-api/internal/bitcoind"
"github.com/kamadopool/kamado-api/internal/ckpool"
"github.com/kamadopool/kamado-api/internal/store"
"github.com/kamadopool/kamado-api/internal/zmqmon"
)
// Snapshot is the merged view served to the UI. All fields are safe to
@@ -81,8 +82,9 @@ func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog
// Run blocks until ctx is cancelled, refreshing the snapshot every Interval.
// It runs one immediate refresh at startup so readers don't see an empty
// snapshot after ctx launches the goroutine. Persisted block history is
// loaded from the store before the first refresh.
func (a *Aggregator) Run(ctx context.Context) {
// loaded from the store before the first refresh. If tipEvents is non-nil,
// each received tip triggers an immediate refresh outside the poll cadence.
func (a *Aggregator) Run(ctx context.Context, tipEvents <-chan zmqmon.TipEvent) {
a.loadPersistedBlocks()
a.refresh(ctx)
t := time.NewTicker(a.Interval)
@@ -93,6 +95,13 @@ func (a *Aggregator) Run(ctx context.Context) {
return
case <-t.C:
a.refresh(ctx)
case ev, ok := <-tipEvents:
if !ok {
tipEvents = nil
continue
}
a.Log.Debug("zmq tip, refreshing", "hash", ev.Hash)
a.refresh(ctx)
}
}
}
+122
View File
@@ -0,0 +1,122 @@
// Package zmqmon subscribes to bitcoind's ZMQ `hashblock` topic and
// emits a lightweight event each time a new chain tip appears.
//
// This is strictly an "immediate refresh trigger" — it does NOT replace
// the ckpool log tailer, which still handles our own solved-block
// detection. ZMQ just shortens the latency between bitcoind seeing a
// new tip and the Kamado dashboard reflecting it (otherwise we'd wait
// up to PollInterval seconds for the next state refresh).
package zmqmon
import (
"context"
"encoding/hex"
"fmt"
"log/slog"
"time"
"github.com/go-zeromq/zmq4"
)
// TipEvent is emitted when bitcoind publishes a new block hash.
type TipEvent struct {
Hash string // lowercase hex, no 0x prefix
SeenAt time.Time
}
// Monitor subscribes to the hashblock topic at Endpoint (e.g.
// "tcp://bitcoind:28332") and publishes TipEvents to Events. Run blocks
// until ctx is cancelled.
type Monitor struct {
Endpoint string
Log *slog.Logger
Events chan TipEvent
}
// New returns a Monitor with a buffered event channel. Endpoint may be
// empty, in which case Run exits immediately as a no-op — useful for
// deployments where ZMQ isn't configured on bitcoind.
func New(endpoint string, log *slog.Logger) *Monitor {
return &Monitor{
Endpoint: endpoint,
Log: log,
Events: make(chan TipEvent, 16),
}
}
// Run connects, subscribes to "hashblock", and relays tip hashes until
// ctx is cancelled. On connection failure it backs off and retries so
// bitcoind restarts don't kill the subscriber permanently.
func (m *Monitor) Run(ctx context.Context) {
defer close(m.Events)
if m.Endpoint == "" {
m.Log.Info("zmqmon disabled (no endpoint)")
return
}
backoff := time.Second
const maxBackoff = 30 * time.Second
for {
if err := m.runOnce(ctx); err != nil && ctx.Err() == nil {
m.Log.Warn("zmqmon reconnecting", "err", err, "backoff", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
continue
}
// ctx cancelled
return
}
}
func (m *Monitor) runOnce(ctx context.Context) error {
sub := zmq4.NewSub(ctx)
defer sub.Close()
if err := sub.Dial(m.Endpoint); err != nil {
return fmt.Errorf("dial %s: %w", m.Endpoint, err)
}
if err := sub.SetOption(zmq4.OptionSubscribe, "hashblock"); err != nil {
return fmt.Errorf("subscribe: %w", err)
}
m.Log.Info("zmqmon connected", "endpoint", m.Endpoint)
for {
// Recv blocks until a message arrives or the underlying context
// is cancelled.
msg, err := sub.Recv()
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("recv: %w", err)
}
// bitcoind publishes 3 frames: topic, body, sequence.
if len(msg.Frames) < 2 {
continue
}
topic := string(msg.Frames[0])
if topic != "hashblock" {
continue
}
hash := hex.EncodeToString(msg.Frames[1])
select {
case m.Events <- TipEvent{Hash: hash, SeenAt: time.Now()}:
case <-ctx.Done():
return nil
default:
// Dropping is fine: the consumer only uses this as a
// "refresh now" signal, not as an event log.
m.Log.Debug("zmqmon event dropped (consumer slow)", "hash", hash)
}
}
}