Harden block-recording pipeline: P0 reliability fixes
Closes the silent-failure modes between "ckpool logs a solve" and "block correctly displayed": * Difficulty estimate matched mempool.space — the projection now uses (inEpoch + 1) intervals so it converges on Bitcoin Core's eventual retarget formula at end-of-epoch instead of undershooting by ~0.05– 0.10 % throughout. * Tailer resumes mid-log on restart — persists (inode, offset) to kv every EOF + on shutdown, and replays the unread tail next time. Any solve line written while kamado-api was down would previously be invisible forever. * Background reconcile loop (60 s) retries hash/reward enrichment for blocks the original RPC missed, so a transient bitcoind-index race no longer permanently leaves a block hashless. * Reorg detection: same loop compares each recent stored hash against getblockhash(height); a mismatch stamps orphaned_at. UI renders these strikethrough with a red "orphaned" tag instead of showing illusory rewards forever. * InsertBlock now reports whether a row was actually inserted; the caller WARN-logs duplicate-height ignores so a re-mined orphaned height can't disappear silently. * Submit-attempt vs confirmed counters surface failed submissions: every "Possible/Submitting block solve" log line increments block_submit_attempts; "Solved and confirmed" increments block_submits_confirmed. A growing gap means bitcoind is rejecting our submissions — previously invisible. * share_err patch refreshed against pinned ckpool source: added SE_NO_JOBID -> 21 and SE_WORKER_MISMATCH -> 24 mappings, kept SE_INVALID_NONCE2 in 20 (it's a malformed-input error, not low-diff). AxeOS users now see actionable Stratum codes instead of "unknown error". UI gets new orphaned_at + block_submit_attempts/confirmed fields on the snapshot type and a strikethrough-with-tag rendering for orphaned blocks in BlocksTable.
This commit is contained in:
@@ -21,22 +21,24 @@ type BlockStore struct {
|
||||
// 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
|
||||
ShareDiff float64
|
||||
Height int64
|
||||
Hash string
|
||||
RewardBT float64
|
||||
FoundAt time.Time
|
||||
Source string
|
||||
ShareDiff float64
|
||||
OrphanedAt time.Time // zero value = not orphaned
|
||||
}
|
||||
|
||||
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 '',
|
||||
share_diff REAL NOT NULL DEFAULT 0
|
||||
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 '',
|
||||
share_diff REAL NOT NULL DEFAULT 0,
|
||||
orphaned_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at);
|
||||
|
||||
@@ -76,6 +78,11 @@ func (s *BlockStore) migrate() error {
|
||||
return fmt.Errorf("store: add share_diff: %w", err)
|
||||
}
|
||||
}
|
||||
if !have["orphaned_at"] {
|
||||
if _, err := s.db.Exec(`ALTER TABLE blocks ADD COLUMN orphaned_at INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return fmt.Errorf("store: add orphaned_at: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,13 +111,25 @@ func (s *BlockStore) Close() error {
|
||||
|
||||
// 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(
|
||||
// Returns (true, nil) if a new row was actually inserted, (false, nil)
|
||||
// if the height was already present (replay or duplicate).
|
||||
//
|
||||
// Limitation: the schema PK is height alone, so a self-mined block at
|
||||
// the same height as a previously-orphaned self-mined block at that
|
||||
// height (vanishingly unlikely for a solo pool) would also be dropped.
|
||||
// Callers should log the (false, nil) case loudly so this never goes
|
||||
// unnoticed.
|
||||
func (s *BlockStore) InsertBlock(b Block) (bool, error) {
|
||||
res, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// HashratePoint is one persisted hashrate sample.
|
||||
@@ -182,7 +201,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
||||
limit = 256
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT height, hash, reward_btc, found_at, source, share_diff
|
||||
`SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at
|
||||
FROM blocks ORDER BY height DESC LIMIT ?`,
|
||||
limit,
|
||||
)
|
||||
@@ -193,11 +212,14 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
||||
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, &b.ShareDiff); err != nil {
|
||||
var foundUnix, orphanedUnix int64
|
||||
if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.FoundAt = time.Unix(unix, 0).UTC()
|
||||
b.FoundAt = time.Unix(foundUnix, 0).UTC()
|
||||
if orphanedUnix > 0 {
|
||||
b.OrphanedAt = time.Unix(orphanedUnix, 0).UTC()
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -205,3 +227,56 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BlocksNeedingEnrichment returns blocks whose hash or reward is still
|
||||
// unset and that were found within the lookback window. Older entries
|
||||
// are ignored — if bitcoind couldn't tell us about a day-old block,
|
||||
// retrying will keep failing.
|
||||
func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at
|
||||
FROM blocks
|
||||
WHERE found_at >= ? AND (hash = '' OR reward_btc = 0)
|
||||
ORDER BY height ASC`,
|
||||
since.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Block
|
||||
for rows.Next() {
|
||||
var b Block
|
||||
var foundUnix, orphanedUnix int64
|
||||
if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.FoundAt = time.Unix(foundUnix, 0).UTC()
|
||||
if orphanedUnix > 0 {
|
||||
b.OrphanedAt = time.Unix(orphanedUnix, 0).UTC()
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateEnrichment fills in hash and reward for an already-recorded
|
||||
// block. No-op if the row doesn't exist.
|
||||
func (s *BlockStore) UpdateEnrichment(height int64, hash string, reward float64) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE blocks SET hash = ?, reward_btc = ? WHERE height = ?`,
|
||||
hash, reward, height,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkOrphaned stamps a block as reorged-out at the given time. The
|
||||
// found_at + reward fields stay so the UI can still render it with a
|
||||
// strikethrough.
|
||||
func (s *BlockStore) MarkOrphaned(height int64, at time.Time) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE blocks SET orphaned_at = ? WHERE height = ?`,
|
||||
at.Unix(), height,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user