diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 07:44:48 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 07:44:48 +0000 |
| commit | f8c0b6d9b477739e0699c4972adba68c5ea20000 (patch) | |
| tree | d17262c044d32f46935381cdeb2662a9d7ccd2e5 | |
| parent | 5892302449e1299f39812d0bb422c4184224b0f1 (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.
| -rw-r--r-- | src/rpc.cpp | 43 |
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 | }; |
| 361 | static std::vector<PendingPayout> gPendingPayouts; | 363 | static std::vector<PendingPayout> gPendingPayouts; |
| 362 | static std::mutex gPayoutLedgerMutex; | 364 | static 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); |