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:
satoshi
2026-04-27 16:30:15 +03:00
parent 81227cb90f
commit df0dbf89e5
8 changed files with 513 additions and 54 deletions
+94 -19
View File
@@ -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
}