upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/rpc.cpp
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 07:44:48 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 07:44:48 +0000
commitf8c0b6d9b477739e0699c4972adba68c5ea20000 (patch)
treed17262c044d32f46935381cdeb2662a9d7ccd2e5 /src/rpc.cpp
parent5892302449e1299f39812d0bb422c4184224b0f1 (diff)
payout: keep one retry clock, the one that is used, and persist it
There were two, and each was broken in the opposite direction. PendingPayout::retryAfter was declared, serialised into pending_payouts.json, loaded back, and tested at the flush gate -- and the only assignment to it anywhere in the tree was `= 0`. The gate could never fire. PendingPayout's `retries` and `deferrals` were the same: carried, never written. Dead fields next to live ones in a structure this one is easy to reach for by mistake, and a gate that looks like backoff but is not is worse than no gate at all. PayingRecord::retryAfter is the clock the payout thread actually uses -- and SavePayingLocked did not write it. Every restart therefore reset every backing-off payment to "ready now" and put the entire in-flight set into the very next 500 ms batch instead of once a minute. On a node restarting in a loop that is a continuous retry storm against a wallet that has already said no, each pass taking cs_main. The dead copy is deleted, along with the flush gate that read it and the one `retryAfter = 0` in RecordPayoutTxid. The live one is now persisted and restored. Retry state stays where the failure happens -- per (round, recipient), which is the granularity SendMoney works at. On disk, both directions still read: - pending_payouts.json no longer has "retryAfter" written into it, and no longer reads one. Files that carry it (everything the merged build wrote, always as 0) load unchanged, the key simply ignored. A build that expects it defaults an absent key to 0, which is the only value it has ever held. - paying_payouts.json gains "retryAfter". Older builds ignore unknown keys; this build defaults an absent one to 0, which is exactly the behaviour a file written without it used to get.
Diffstat (limited to 'src/rpc.cpp')
-rw-r--r--src/rpc.cpp43
1 files changed, 27 insertions, 16 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index 6f6d101..d655ad5 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -352,11 +352,13 @@ struct PendingPayout {
352 // lets an entry stay on disk until every address in `amounts` is paid, 352 // lets an entry stay on disk until every address in `amounts` is paid,
353 // instead of being erased the moment the batch is handed off. 353 // instead of being erased the moment the batch is handed off.
354 std::map<std::string, std::string> paidTxids; // address -> txid 354 std::map<std::string, std::string> paidTxids; // address -> txid
355 int retries = 0; // exec-thread only: SendMoney attempts so far 355 // Retry and backoff state does NOT live here. It used to be declared here
356 int deferrals = 0; // exec-thread only: waits for spendable coin 356 // as well as on PayingRecord, and the copy on this side was never written:
357 // Upstream's `nextAttempt` is this field under another name; ours is kept 357 // `retryAfter` was declared, serialised, loaded and tested at the flush
358 // so the retry/deferral policy above stays coherent with it. 358 // gate, and the only assignment to it anywhere was `= 0`, so the gate could
359 int64 retryAfter = 0; // exec-thread only: unix time, 0 = ready now 359 // not fire. `retries` and `deferrals` were the same story. The real state
360 // is per (round, recipient) -- which is the granularity a payment can fail
361 // at -- and it lives on PayingRecord, which is now what persists it.
360}; 362};
361static std::vector<PendingPayout> gPendingPayouts; 363static std::vector<PendingPayout> gPendingPayouts;
362static std::mutex gPayoutLedgerMutex; 364static std::mutex gPayoutLedgerMutex;
@@ -597,7 +599,11 @@ static VersionedBlob SerializePendingPayoutsLocked()
597 obj["blockHeight"] = pp.blockHeight; 599 obj["blockHeight"] = pp.blockHeight;
598 obj["blockHash"] = pp.blockHash.ToString(); 600 obj["blockHash"] = pp.blockHash.ToString();
599 obj["matureAtHeight"] = pp.matureAtHeight; 601 obj["matureAtHeight"] = pp.matureAtHeight;
600 obj["retryAfter"] = pp.retryAfter; 602 // No retryAfter: it was never written with anything but zero, and the
603 // retry clock that is actually used is on PayingRecord. Files that
604 // still carry the key load fine (it is ignored), and a build that
605 // expects it defaults an absent one to 0, which means "ready now" --
606 // the same answer it always got.
601 nlohmann::json amounts = nlohmann::json::object(); 607 nlohmann::json amounts = nlohmann::json::object();
602 for (const auto& kv : pp.amounts) 608 for (const auto& kv : pp.amounts)
603 amounts[kv.first] = kv.second; 609 amounts[kv.first] = kv.second;
@@ -655,10 +661,9 @@ static void LoadPendingPayouts()
655 // still COINBASE_MATURITY. 661 // still COINBASE_MATURITY.
656 if (pp.blockHeight == 0 && pp.matureAtHeight > COINBASE_MATURITY) 662 if (pp.blockHeight == 0 && pp.matureAtHeight > COINBASE_MATURITY)
657 pp.blockHeight = pp.matureAtHeight - COINBASE_MATURITY; 663 pp.blockHeight = pp.matureAtHeight - COINBASE_MATURITY;
658 // Absent in every file written before this merge; default 0 means 664 // "retryAfter" is deliberately not read. Files written by the
659 // "ready now", which is the right answer for a restored entry. 665 // merged build carry it, always as 0, and there is nowhere left to
660 if (obj.contains("retryAfter") && obj["retryAfter"].is_number()) 666 // put it -- the retry clock is PayingRecord's.
661 pp.retryAfter = obj["retryAfter"].get<int64>();
662 for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) 667 for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it)
663 pp.amounts[it.key()] = it.value().get<int64>(); 668 pp.amounts[it.key()] = it.value().get<int64>();
664 if (obj.contains("paidTxids") && obj["paidTxids"].is_object()) 669 if (obj.contains("paidTxids") && obj["paidTxids"].is_object())
@@ -777,6 +782,11 @@ static VersionedBlob SerializePayingLocked()
777 obj["amount"] = r.amount; 782 obj["amount"] = r.amount;
778 obj["retries"] = r.retries; 783 obj["retries"] = r.retries;
779 obj["deferrals"] = r.deferrals; 784 obj["deferrals"] = r.deferrals;
785 // The live retry clock, which used not to be written at all: every
786 // restart handed every backing-off payment a retryAfter of 0 and put
787 // the whole set into the very next 500 ms batch, so a node restarted
788 // in a loop retried continuously instead of once a minute.
789 obj["retryAfter"] = r.retryAfter;
780 arr.push_back(obj); 790 arr.push_back(obj);
781 } 791 }
782 VersionedBlob v; 792 VersionedBlob v;
@@ -818,6 +828,10 @@ static void LoadPaying()
818 r.retries = obj["retries"].get<int>(); 828 r.retries = obj["retries"].get<int>();
819 if (obj.contains("deferrals") && obj["deferrals"].is_number()) 829 if (obj.contains("deferrals") && obj["deferrals"].is_number())
820 r.deferrals = obj["deferrals"].get<int>(); 830 r.deferrals = obj["deferrals"].get<int>();
831 // Absent in files written before this change; 0 means ready now,
832 // which is exactly the behaviour those files used to get.
833 if (obj.contains("retryAfter") && obj["retryAfter"].is_number())
834 r.retryAfter = obj["retryAfter"].get<int64>();
821 // Collapse duplicates as they are read. A build without the 835 // Collapse duplicates as they are read. A build without the
822 // ReconcilePayouts seeding below wrote a second (and after another 836 // ReconcilePayouts seeding below wrote a second (and after another
823 // restart, a third) byte-identical record for every unpaid 837 // restart, a third) byte-identical record for every unpaid
@@ -834,8 +848,9 @@ static void LoadPaying()
834 break; 848 break;
835 } 849 }
836 if (dup) { 850 if (dup) {
837 dup->retries = std::max(dup->retries, r.retries); 851 dup->retries = std::max(dup->retries, r.retries);
838 dup->deferrals = std::max(dup->deferrals, r.deferrals); 852 dup->deferrals = std::max(dup->deferrals, r.deferrals);
853 dup->retryAfter = std::max(dup->retryAfter, r.retryAfter);
839 LogPrint("payout", "[payout] collapsing duplicate in-flight" 854 LogPrint("payout", "[payout] collapsing duplicate in-flight"
840 " record for %s to %s -- one payment, one record\n", 855 " record for %s to %s -- one payment, one record\n",
841 FormatMoney(r.amount).c_str(), r.address.c_str()); 856 FormatMoney(r.amount).c_str(), r.address.c_str());
@@ -1505,12 +1520,9 @@ static void FlushMaturePayouts()
1505 } 1520 }
1506 if (voided) ledgerSnap = SerializePendingPayoutsLocked(); 1521 if (voided) ledgerSnap = SerializePendingPayoutsLocked();
1507 1522
1508 int64 now = GetTime();
1509 for (const PendingPayout& pp : gPendingPayouts) { 1523 for (const PendingPayout& pp : gPendingPayouts) {
1510 if (nBestHeight < pp.matureAtHeight) 1524 if (nBestHeight < pp.matureAtHeight)
1511 continue; 1525 continue;
1512 if (pp.retryAfter > now)
1513 continue;
1514 if (PayoutFullyPaid(pp)) 1526 if (PayoutFullyPaid(pp))
1515 continue; 1527 continue;
1516 // An entry with neither a hash nor a height cannot be told apart 1528 // An entry with neither a hash nor a height cannot be told apart
@@ -1658,7 +1670,6 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid)
1658 r.blockHeight, r.blockHash)) 1670 r.blockHeight, r.blockHash))
1659 continue; 1671 continue;
1660 it->paidTxids[r.address] = txid; 1672 it->paidTxids[r.address] = txid;
1661 it->retryAfter = 0;
1662 if (PayoutFullyPaid(*it)) { 1673 if (PayoutFullyPaid(*it)) {
1663 LogPrint("payout", "[payout] payout for block %d complete --" 1674 LogPrint("payout", "[payout] payout for block %d complete --"
1664 " removing from ledger\n", it->blockHeight); 1675 " removing from ledger\n", it->blockHeight);