Persist found blocks in SQLite (Phase 2b.5)
New internal/store package wraps modernc.org/sqlite (pure Go, no CGO) with a BlockStore that exposes Open/Close/InsertBlock/ Recent. The aggregator now accepts an optional *store.BlockStore; on Run() it loads up to maxBlockHistory rows from the store before the first refresh, and each ingested block gets written to the DB before being appended to the in-memory ring. main.go opens the store at cfg.DBPath and logs a warning + falls back to in-memory-only if the file can't be created — a broken data volume shouldn't stop the pool from running. InsertBlock uses INSERT OR IGNORE on the height primary key so replayed log events after a restart are harmless.
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/kamadopool/kamado-api/internal/httpapi"
|
"github.com/kamadopool/kamado-api/internal/httpapi"
|
||||||
"github.com/kamadopool/kamado-api/internal/logmon"
|
"github.com/kamadopool/kamado-api/internal/logmon"
|
||||||
"github.com/kamadopool/kamado-api/internal/state"
|
"github.com/kamadopool/kamado-api/internal/state"
|
||||||
|
"github.com/kamadopool/kamado-api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -41,7 +42,23 @@ func main() {
|
|||||||
|
|
||||||
ck := ckpool.New(cfg.CKPoolSockDir)
|
ck := ckpool.New(cfg.CKPoolSockDir)
|
||||||
rpc := bitcoind.NewRPC(cfg.BitcoinRPCURL, cfg.BitcoinRPCUser, cfg.BitcoinRPCPassword, cfg.BitcoinRPCTimeout)
|
rpc := bitcoind.NewRPC(cfg.BitcoinRPCURL, cfg.BitcoinRPCUser, cfg.BitcoinRPCPassword, cfg.BitcoinRPCTimeout)
|
||||||
|
|
||||||
|
// Persistent block history. Non-fatal if it can't be opened — the
|
||||||
|
// aggregator falls back to an in-memory ring so the pool keeps
|
||||||
|
// running even with a broken data volume.
|
||||||
|
var blockStore *store.BlockStore
|
||||||
|
if cfg.DBPath != "" {
|
||||||
|
if s, err := store.Open(cfg.DBPath); err != nil {
|
||||||
|
log.Warn("block store open failed, running without persistence", "path", cfg.DBPath, "err", err)
|
||||||
|
} else {
|
||||||
|
blockStore = s
|
||||||
|
defer blockStore.Close()
|
||||||
|
log.Info("block store opened", "path", cfg.DBPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
agg := state.New(ck, rpc, cfg.PollInterval, log)
|
agg := state.New(ck, rpc, cfg.PollInterval, log)
|
||||||
|
agg.Store = blockStore
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
+14
@@ -1,3 +1,17 @@
|
|||||||
module github.com/kamadopool/kamado-api
|
module github.com/kamadopool/kamado-api
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
|
require modernc.org/sqlite v1.34.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kamadopool/kamado-api/internal/bitcoind"
|
"github.com/kamadopool/kamado-api/internal/bitcoind"
|
||||||
"github.com/kamadopool/kamado-api/internal/ckpool"
|
"github.com/kamadopool/kamado-api/internal/ckpool"
|
||||||
|
"github.com/kamadopool/kamado-api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Snapshot is the merged view served to the UI. All fields are safe to
|
// Snapshot is the merged view served to the UI. All fields are safe to
|
||||||
@@ -49,6 +50,7 @@ type Snapshot struct {
|
|||||||
type Aggregator struct {
|
type Aggregator struct {
|
||||||
CK *ckpool.Client
|
CK *ckpool.Client
|
||||||
RPC *bitcoind.RPC
|
RPC *bitcoind.RPC
|
||||||
|
Store *store.BlockStore // optional; nil disables persistence
|
||||||
Interval time.Duration
|
Interval time.Duration
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
|
||||||
@@ -78,8 +80,10 @@ func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog
|
|||||||
|
|
||||||
// Run blocks until ctx is cancelled, refreshing the snapshot every Interval.
|
// 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
|
// It runs one immediate refresh at startup so readers don't see an empty
|
||||||
// snapshot after ctx launches the goroutine.
|
// 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) {
|
func (a *Aggregator) Run(ctx context.Context) {
|
||||||
|
a.loadPersistedBlocks()
|
||||||
a.refresh(ctx)
|
a.refresh(ctx)
|
||||||
t := time.NewTicker(a.Interval)
|
t := time.NewTicker(a.Interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kamadopool/kamado-api/internal/logmon"
|
"github.com/kamadopool/kamado-api/internal/logmon"
|
||||||
|
"github.com/kamadopool/kamado-api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BlockRecord is a found block, merged from a logmon event with bitcoind
|
// BlockRecord is a found block, merged from a logmon event with bitcoind
|
||||||
@@ -44,6 +45,17 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
|
|||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
if a.Store != nil {
|
||||||
|
if err := a.Store.InsertBlock(store.Block{
|
||||||
|
Height: rec.Height,
|
||||||
|
Hash: rec.Hash,
|
||||||
|
RewardBT: rec.RewardBT,
|
||||||
|
FoundAt: rec.FoundAt,
|
||||||
|
Source: rec.Source,
|
||||||
|
}); err != nil {
|
||||||
|
a.Log.Warn("block persist failed", "height", rec.Height, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
pushed := a.appendBlock(rec)
|
pushed := a.appendBlock(rec)
|
||||||
a.Log.Info("block recorded", "height", rec.Height, "hash", rec.Hash)
|
a.Log.Info("block recorded", "height", rec.Height, "hash", rec.Hash)
|
||||||
// Push immediately so WebSocket clients see the solve
|
// Push immediately so WebSocket clients see the solve
|
||||||
@@ -77,6 +89,35 @@ func (a *Aggregator) appendBlock(rec BlockRecord) Snapshot {
|
|||||||
return snap
|
return snap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPersistedBlocks seeds a.blocks from the store so history survives
|
||||||
|
// restarts. Safe to call with a nil Store — becomes a no-op.
|
||||||
|
func (a *Aggregator) loadPersistedBlocks() {
|
||||||
|
if a.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := a.Store.Recent(maxBlockHistory)
|
||||||
|
if err != nil {
|
||||||
|
a.Log.Warn("block history load failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Store returns newest-first; a.blocks is newest-last.
|
||||||
|
recs := make([]BlockRecord, 0, len(rows))
|
||||||
|
for i := len(rows) - 1; i >= 0; i-- {
|
||||||
|
r := rows[i]
|
||||||
|
recs = append(recs, BlockRecord{
|
||||||
|
Height: r.Height,
|
||||||
|
Hash: r.Hash,
|
||||||
|
RewardBT: r.RewardBT,
|
||||||
|
FoundAt: r.FoundAt,
|
||||||
|
Source: r.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
a.blocks = recs
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.Log.Info("block history loaded", "count", len(recs))
|
||||||
|
}
|
||||||
|
|
||||||
// Blocks returns a copy of the recent block history, newest last.
|
// Blocks returns a copy of the recent block history, newest last.
|
||||||
func (a *Aggregator) Blocks() []BlockRecord {
|
func (a *Aggregator) Blocks() []BlockRecord {
|
||||||
a.mu.RLock()
|
a.mu.RLock()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Package store persists Kamado runtime data that must survive process
|
||||||
|
// restarts. Currently: the found-block history. Uses modernc.org/sqlite
|
||||||
|
// (pure Go, no CGO) so the kamado-api binary stays statically linkable.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlockStore persists found blocks to a single SQLite file.
|
||||||
|
type BlockStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block mirrors state.BlockRecord without the import cycle. The store
|
||||||
|
// package is the lower layer; the state package converts to/from this
|
||||||
|
// shape when reading and writing.
|
||||||
|
type Block struct {
|
||||||
|
Height int64
|
||||||
|
Hash string
|
||||||
|
RewardBT float64
|
||||||
|
FoundAt time.Time
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS blocks (
|
||||||
|
height INTEGER PRIMARY KEY,
|
||||||
|
hash TEXT NOT NULL DEFAULT '',
|
||||||
|
reward_btc REAL NOT NULL DEFAULT 0,
|
||||||
|
found_at INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at);
|
||||||
|
`
|
||||||
|
|
||||||
|
// Open initializes the store at path, creating the schema if needed.
|
||||||
|
// Callers are responsible for Close().
|
||||||
|
func Open(path string) (*BlockStore, error) {
|
||||||
|
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("store: schema: %w", err)
|
||||||
|
}
|
||||||
|
return &BlockStore{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BlockStore) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertBlock is idempotent — duplicate heights are ignored so replayed
|
||||||
|
// log events after a restart don't trip the primary key constraint.
|
||||||
|
func (s *BlockStore) InsertBlock(b Block) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns up to limit blocks, newest first.
|
||||||
|
func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 256
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT height, hash, reward_btc, found_at, source
|
||||||
|
FROM blocks ORDER BY height DESC LIMIT ?`,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]Block, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var b Block
|
||||||
|
var unix int64
|
||||||
|
if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &unix, &b.Source); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.FoundAt = time.Unix(unix, 0).UTC()
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user