diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:46:03 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:46:03 +0000 |
| commit | 9e5dc16941ac34e77157d85692d1774d8698b1b4 (patch) | |
| tree | a2fe11071d42d8743795ca655bd64ff3f3444598 /src | |
| parent | 3ec75952d9142bb5aa58307fc5a07db264ecdb24 (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.
Diffstat (limited to 'src')
| -rw-r--r-- | src/rpc.cpp | 173 |
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. | ||
| 602 | static std::set<std::string> gRefusedFiles; | ||
| 603 | static std::mutex gRefusedFilesMutex; | ||
| 604 | |||
| 605 | static 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. | ||
| 616 | static 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 |