upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 11:46:03 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 11:46:03 +0000
commit9e5dc16941ac34e77157d85692d1774d8698b1b4 (patch)
treea2fe11071d42d8743795ca655bd64ff3f3444598
parent3ec75952d9142bb5aa58307fc5a07db264ecdb24 (diff)
payout: quarantine a ledger that failed to load instead of overwriting it
All three loaders push entries into the live structure inside the try block, so an exception part way down a file leaves a PARTIAL picture in memory: the entries before the bad one, and nothing after it. Both loaders then return normally, having logged one line under a debug category, and the node carries on as though that were the whole ledger. The first publish afterwards writes the partial state over the file. ReconcilePayouts publishes unconditionally during startup, so that is milliseconds later -- before an operator could see the warning, let alone act on it. The only durable record of what the pool owed is then gone, and it is gone in the direction that costs miners money: the entries that failed to parse are the ones that vanish. 86b39b9's own commit message names this failure mode and then fixes only the write side. On any parse exception the file is now renamed to <path>.bad.<timestamp>, the path is added to a refusal set, and PublishVersioned declines to write it for the rest of the process. A node that has lost its ledger now comes up visibly missing it rather than quietly appearing to owe nobody anything, and the bytes are still there to reconcile against. Restarting into the same fault does not consume the evidence, because the quarantine name carries the time. Logged with printf rather than LogPrint: an operator who has just lost a payout ledger has to be told at whatever settings they were already running, and the name the file was moved to is the only route back to the data. Property: a payout file that did not parse completely is never overwritten by what did parse.
-rw-r--r--src/rpc.cpp173
1 files changed, 165 insertions, 8 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index b0436ba..7d7cc8f 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -583,6 +583,55 @@ static bool WriteFileAtomic(const std::string& path, const std::string& data,
583 return true; 583 return true;
584} 584}
585 585
586// Paths whose loader threw part-way through, and which must therefore never be
587// written again by this process.
588//
589// All three loaders push entries into the live structure INSIDE the try, so an
590// exception half way down a file leaves a PARTIAL ledger in memory -- the
591// entries before the bad one, and nothing after it. The first publish after
592// that then writes the partial state over the file, and the only record of
593// what was owed is gone. ReconcilePayouts publishes unconditionally at
594// startup, so "the first publish" is a few milliseconds later, before an
595// operator could possibly intervene. a8e2f58 named this failure mode in its
596// own commit message and then only fixed the write side.
597//
598// The file is renamed out of the way instead of being left in place, so the
599// evidence survives even if the operator restarts into the same fault, and so
600// a node that comes up owing nobody anything is visibly missing its ledger
601// rather than quietly appearing to have none.
602static std::set<std::string> gRefusedFiles;
603static std::mutex gRefusedFilesMutex;
604
605static bool FileIsRefused(const std::string& path)
606{
607 std::lock_guard<std::mutex> lk(gRefusedFilesMutex);
608 return gRefusedFiles.count(path) > 0;
609}
610
611// Quarantine `path` and refuse to write it for the rest of the process.
612//
613// Prints unconditionally: an operator who has just lost a payout ledger must
614// be told at whatever log settings they were already running with, and the
615// name it was moved to is the only way back to the data.
616static void RefuseFileAfterLoadFailure(const std::string& path, const char* what,
617 const char* why)
618{
619 {
620 std::lock_guard<std::mutex> lk(gRefusedFilesMutex);
621 gRefusedFiles.insert(path);
622 }
623 std::string bad = strprintf("%s.bad.%lld", path.c_str(), (long long)GetTime());
624 bool moved = (rename(path.c_str(), bad.c_str()) == 0);
625 printf("[payout] ERROR: %s (%s) did not parse: %s\n"
626 "[payout] ERROR: what loaded before the error is a PARTIAL picture,"
627 " so this file will not be written again by this process --"
628 " restart once the cause is fixed\n"
629 "[payout] ERROR: the file %s\n",
630 path.c_str(), what, why,
631 moved ? strprintf("has been kept as %s", bad.c_str()).c_str()
632 : "could NOT be renamed out of the way -- copy it now");
633}
634
586// Publish a versioned image of some in-memory state to `path`. 635// Publish a versioned image of some in-memory state to `path`.
587// 636//
588// `fileMutex` serialises writers of `path` and guards `publishedSeq`. A blob 637// `fileMutex` serialises writers of `path` and guards `publishedSeq`. A blob
@@ -595,6 +644,11 @@ static void PublishVersioned(const std::string& path, const VersionedBlob& v,
595 std::mutex& fileMutex, uint64& publishedSeq, 644 std::mutex& fileMutex, uint64& publishedSeq,
596 const char* what) 645 const char* what)
597{ 646{
647 // A file whose loader threw is not a file this process knows the contents
648 // of. Writing it would replace a full record of what was owed with
649 // whatever happened to parse before the error.
650 if (FileIsRefused(path))
651 return;
598 std::lock_guard<std::mutex> lk(fileMutex); 652 std::lock_guard<std::mutex> lk(fileMutex);
599 if (v.seq <= publishedSeq) 653 if (v.seq <= publishedSeq)
600 return; 654 return;
@@ -760,8 +814,7 @@ static void LoadPendingPayouts()
760 LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n", 814 LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n",
761 gPendingPayouts.size()); 815 gPendingPayouts.size());
762 } catch (const std::exception& e) { 816 } catch (const std::exception& e) {
763 LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", 817 RefuseFileAfterLoadFailure(path, "pending payout ledger", e.what());
764 path.c_str(), e.what());
765 } 818 }
766} 819}
767 820
@@ -948,8 +1001,7 @@ static void LoadPaying()
948 LogPrint("payout", "[payout] loaded %zu in-flight payment(s) from disk\n", 1001 LogPrint("payout", "[payout] loaded %zu in-flight payment(s) from disk\n",
949 gPaying.size()); 1002 gPaying.size());
950 } catch (const std::exception& e) { 1003 } catch (const std::exception& e) {
951 LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", 1004 RefuseFileAfterLoadFailure(path, "in-flight payment list", e.what());
952 path.c_str(), e.what());
953 } 1005 }
954} 1006}
955 1007
@@ -1095,8 +1147,7 @@ static void LoadPoolRounds()
1095 LogPrint("payout", "[payout] loaded %zu pool round proof(s) from disk\n", 1147 LogPrint("payout", "[payout] loaded %zu pool round proof(s) from disk\n",
1096 gPoolRounds.size()); 1148 gPoolRounds.size());
1097 } catch (const std::exception& e) { 1149 } catch (const std::exception& e) {
1098 LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", 1150 RefuseFileAfterLoadFailure(path, "pool round proofs", e.what());
1099 path.c_str(), e.what());
1100 } 1151 }
1101} 1152}
1102 1153
@@ -3021,6 +3072,12 @@ static void PayoutTestResetState()
3021 std::lock_guard<std::mutex> lk(gClaimedTxidMutex); 3072 std::lock_guard<std::mutex> lk(gClaimedTxidMutex);
3022 gLegacyClaimedTxids.clear(); 3073 gLegacyClaimedTxids.clear();
3023 } 3074 }
3075 {
3076 // A quarantine is meant to last the life of the process; each section
3077 // here is a fresh process as far as the code under test is concerned.
3078 std::lock_guard<std::mutex> lk(gRefusedFilesMutex);
3079 gRefusedFiles.clear();
3080 }
3024} 3081}
3025 3082
3026// A BTF address whose hash160 is entirely under the test's control, so the 3083// A BTF address whose hash160 is entirely under the test's control, so the
@@ -3785,7 +3842,107 @@ int RunPayoutLedgerSelfTest()
3785 strPoolRoundsFile = savedRoundsFile; 3842 strPoolRoundsFile = savedRoundsFile;
3786 } 3843 }
3787 3844
3788 // -- 11. A send that failed for want of coin is not a fault -------------- 3845 // -- 11. A ledger that failed to load is never written back --------------
3846 //
3847 // The loaders push entries into the live vector inside the try, so an
3848 // exception part way down a file leaves a PARTIAL ledger in memory: the
3849 // entries before the bad one and nothing after it. Publishing that -- and
3850 // ReconcilePayouts publishes unconditionally, milliseconds into startup --
3851 // writes the partial state over the file and destroys the only record of
3852 // what was owed.
3853 {
3854 PayoutTestResetState();
3855 remove(ledgerPath.c_str());
3856
3857 // Two entries, the second with a matureAtHeight that is not a number.
3858 // The first loads; the second throws where it is read.
3859 const std::string corrupt = strprintf(
3860 "[{\"amounts\":{\"%s\":100000000},\"blockHash\":\"%s\","
3861 "\"blockHeight\":10,\"matureAtHeight\":10},"
3862 "{\"amounts\":{\"%s\":900000000},\"blockHash\":\"%s\","
3863 "\"blockHeight\":20,\"matureAtHeight\":\"twenty\"}]",
3864 addrA.c_str(), uint256(0).ToString().c_str(),
3865 addrB.c_str(), uint256(0).ToString().c_str());
3866 check(PayoutTestWriteFile(ledgerPath, corrupt),
3867 "wrote a ledger whose second entry does not parse");
3868
3869 const int64 t0 = GetTime();
3870 LoadPendingPayouts();
3871 const int64 t1 = GetTime();
3872
3873 size_t loaded = 0;
3874 {
3875 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3876 loaded = gPendingPayouts.size();
3877 }
3878 check(loaded == 1,
3879 "a mid-file parse error does leave a partial ledger in memory --"
3880 " which is the reason for everything below");
3881
3882 std::string gone;
3883 check(!PayoutTestReadFile(ledgerPath, gone),
3884 "the file that did not parse is moved out of the way");
3885
3886 // The rename stamps GetTime(); it was taken between t0 and t1.
3887 std::string kept, keptPath;
3888 for (int64 t = t0; t <= t1 && keptPath.empty(); t++) {
3889 const std::string candidate =
3890 strprintf("%s.bad.%lld", ledgerPath.c_str(), (long long)t);
3891 if (PayoutTestReadFile(candidate, kept))
3892 keptPath = candidate;
3893 }
3894 check(!keptPath.empty() && kept == corrupt,
3895 "it is kept, byte for byte, as <path>.bad.<timestamp>");
3896
3897 // The publish that used to destroy it.
3898 VersionedBlob partial;
3899 {
3900 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3901 partial = SerializePendingPayoutsLocked();
3902 }
3903 PublishPendingPayouts(partial);
3904 check(!PayoutTestReadFile(ledgerPath, gone),
3905 "and the partial ledger is not published over it");
3906
3907 // The refusal is per path and lasts the process: a second attempt,
3908 // with more state, is refused too.
3909 {
3910 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3911 PendingPayout pp;
3912 pp.blockHeight = 30; pp.blockHash = 0; pp.matureAtHeight = 30;
3913 pp.amounts[addrA] = 5 * COIN;
3914 gPendingPayouts.push_back(pp);
3915 partial = SerializePendingPayoutsLocked();
3916 }
3917 PublishPendingPayouts(partial);
3918 check(!PayoutTestReadFile(ledgerPath, gone),
3919 "the refusal is not a one-shot -- the path stays unwritable");
3920
3921 if (!keptPath.empty())
3922 remove(keptPath.c_str());
3923 PayoutTestResetState();
3924 remove(ledgerPath.c_str());
3925
3926 // And a file that parses is not quarantined, so the ordinary path is
3927 // still the ordinary path.
3928 check(PayoutTestWriteFile(ledgerPath, "[]"),
3929 "wrote a ledger that parses");
3930 LoadPendingPayouts();
3931 {
3932 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3933 PendingPayout pp;
3934 pp.blockHeight = 40; pp.blockHash = 0; pp.matureAtHeight = 40;
3935 pp.amounts[addrA] = 2 * COIN;
3936 gPendingPayouts.push_back(pp);
3937 partial = SerializePendingPayoutsLocked();
3938 }
3939 PublishPendingPayouts(partial);
3940 std::string written;
3941 check(PayoutTestReadFile(ledgerPath, written) && written == partial.blob,
3942 "a ledger that loaded cleanly is still published normally");
3943 }
3944
3945 // -- 12. A send that failed for want of coin is not a fault --------------
3789 // 3946 //
3790 // The defect this replaces: the classifier decided "was this a funding 3947 // The defect this replaces: the classifier decided "was this a funding
3791 // problem?" by re-reading GetBalance() and allowing amount + 3948 // problem?" by re-reading GetBalance() and allowing amount +
@@ -3919,7 +4076,7 @@ int RunPayoutLedgerSelfTest()
3919 mapWallet.clear(); 4076 mapWallet.clear();
3920 } 4077 }
3921 4078
3922 // -- 12. No payout mutex is held across the status-file write ------------ 4079 // -- 13. No payout mutex is held across the status-file write ------------
3923 // 4080 //
3924 // RefreshStats runs on the event loop -- the thread that owns every 4081 // RefreshStats runs on the event loop -- the thread that owns every
3925 // miner's socket -- and used to hold gStatsMutex and gPayoutLedgerMutex at 4082 // miner's socket -- and used to hold gStatsMutex and gPayoutLedgerMutex at