diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:53:20 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:53:20 +0000 |
| commit | 111fb886c2a38c8ab76fd064c9dbb1f403fde7c1 (patch) | |
| tree | caff3d96d9bd13d9096b5d90169ed908f942dd8f /src | |
| parent | 46864cdf3c90d15d5d21a330bb54f43696aa7d3b (diff) | |
selftest: make a torn publish structurally invalid, not merely unlucky
Mutation-tested, the concurrency test did not test concurrency. With the
per-file write mutex removed from PublishVersioned, `make tests` still
went green 2 runs out of 5, and the assertion named "four threads
publishing the same file never leave it unparseable" fired 0 times out
of 5. It was asserting something true for reasons unrelated to the code
it was pointed at.
Two causes.
The images were 64 arrays of 200 entries, identical in structure and
within bytes of each other in length, so an interleaving of any two of
them was still a valid JSON array. There was nothing structural for a
torn write to break. They now alternate between one entry and five
thousand: a write landing on top of another leaves either a truncated
array or a small image followed by the tail of a large one, and neither
parses.
Worse, almost nothing was being written. Each thread walked a
permutation of the images and PublishVersioned drops any blob not newer
than what is on disk, so only the record highs in each permutation ever
reached the disk -- about ten writes in total across four threads, which
is not enough contention to produce a race however the images are
shaped. Every publish now stamps its sequence number from a shared
counter at the moment it is made, so every publish is newer than the
last one issued and every publish writes: 400 of them, half of them
600 KB.
And the file is now watched while the writers run rather than inspected
once after they stop. "The published file is never observed
unparseable" is the real invariant -- rename(2) is atomic, so a reader
can only ever see a completely written image -- and sampling it only at
the end looks at the one moment a torn file is least likely to have
survived.
Measured on the build host, five runs with the file mutex removed: 5
detections out of 5, every one of them on the unparseable-file
assertion, which is the assertion that used to fire never. Five runs
with the mutex in place: 5 passes. The whole payout-ledger self-test
takes 1.4 s.
Property: removing the serialisation this test exists to check fails the
test, on every run.
Diffstat (limited to 'src')
| -rw-r--r-- | src/rpc.cpp | 128 |
1 files changed, 96 insertions, 32 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp index 7ea0597..79947df 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp | |||
| @@ -3542,54 +3542,118 @@ int RunPayoutLedgerSelfTest() | |||
| 3542 | 3542 | ||
| 3543 | // -- 6. Concurrent publishers cannot interleave a file ------------------- | 3543 | // -- 6. Concurrent publishers cannot interleave a file ------------------- |
| 3544 | // | 3544 | // |
| 3545 | // Two threads renaming the same "<path>.tmp" over the same target was the | 3545 | // Two threads opening and renaming the same "<path>.tmp" over the same |
| 3546 | // original defect. The images are made big so a torn write shows up as | 3546 | // target was the original defect. This test was mutation-tested and did |
| 3547 | // unparseable JSON rather than by luck. | 3547 | // not catch it: with the file mutex removed the suite still went green 2 |
| 3548 | // runs in 5, and the "never leave it unparseable" assertion fired 0 times | ||
| 3549 | // in 5. Two reasons, both fixed here. | ||
| 3550 | // | ||
| 3551 | // First, every image had the same structure and near enough the same | ||
| 3552 | // length, so an interleaving of two of them was still valid JSON. The | ||
| 3553 | // images now alternate between ONE entry and FIVE THOUSAND, so a write | ||
| 3554 | // that lands on top of another leaves either a truncated array or a small | ||
| 3555 | // image followed by the tail of a large one -- neither of which parses. | ||
| 3556 | // | ||
| 3557 | // Second, and worse, almost nothing was being written. Each thread walked | ||
| 3558 | // a permutation of the images, and PublishVersioned drops any blob that is | ||
| 3559 | // not newer than what is on disk, so only the record highs in each | ||
| 3560 | // permutation ever reached the disk -- on the order of ten writes in | ||
| 3561 | // total, four threads, no contention to speak of. Every publish here is | ||
| 3562 | // stamped from a shared counter at the moment it is made, so every one of | ||
| 3563 | // them is newer than the last and every one of them writes. | ||
| 3564 | // | ||
| 3565 | // And the file is watched while the writers run, not only inspected after | ||
| 3566 | // they finish. "The published file is never observed unparseable" is the | ||
| 3567 | // actual invariant -- rename(2) is atomic, so a reader can only ever see a | ||
| 3568 | // completely written image -- and checking it once at the end samples the | ||
| 3569 | // one moment when a torn file is least likely to still be there. | ||
| 3548 | { | 3570 | { |
| 3549 | const std::string racePath = dir + "/publish_race_selftest.json"; | 3571 | const std::string racePath = dir + "/publish_race_selftest.json"; |
| 3550 | std::mutex raceMutex; | 3572 | std::mutex raceMutex; |
| 3551 | uint64 racePublished = 0; | 3573 | uint64 racePublished = 0; |
| 3552 | 3574 | ||
| 3553 | std::vector<VersionedBlob> images; | 3575 | std::string tiny, huge; |
| 3554 | for (int i = 0; i < 64; i++) { | 3576 | { |
| 3555 | nlohmann::json arr = nlohmann::json::array(); | 3577 | nlohmann::json one = nlohmann::json::array(); |
| 3556 | for (int k = 0; k < 200; k++) { | 3578 | nlohmann::json o; |
| 3557 | nlohmann::json o; | 3579 | o["image"] = 0; |
| 3558 | o["image"] = i; | 3580 | o["entry"] = 0; |
| 3559 | o["entry"] = k; | 3581 | o["address"] = addrA; |
| 3560 | o["address"] = addrA; | 3582 | o["amount"] = (int64)COIN; |
| 3561 | o["amount"] = (int64)(k + 1) * COIN; | 3583 | one.push_back(o); |
| 3562 | arr.push_back(o); | 3584 | tiny = one.dump(2); |
| 3585 | |||
| 3586 | nlohmann::json many = nlohmann::json::array(); | ||
| 3587 | for (int k = 0; k < 5000; k++) { | ||
| 3588 | nlohmann::json e; | ||
| 3589 | e["image"] = 1; | ||
| 3590 | e["entry"] = k; | ||
| 3591 | e["address"] = addrA; | ||
| 3592 | e["amount"] = (int64)(k + 1) * COIN; | ||
| 3593 | many.push_back(e); | ||
| 3563 | } | 3594 | } |
| 3564 | VersionedBlob v; | 3595 | huge = many.dump(2); |
| 3565 | v.blob = arr.dump(2); | ||
| 3566 | v.seq = (uint64)i + 1; | ||
| 3567 | images.push_back(v); | ||
| 3568 | } | 3596 | } |
| 3569 | 3597 | ||
| 3598 | const int kThreads = 4; | ||
| 3599 | const int kPerThread = 100; | ||
| 3600 | const uint64 kTotal = (uint64)kThreads * kPerThread; | ||
| 3601 | |||
| 3602 | std::atomic<uint64> nextSeq(0); | ||
| 3603 | std::atomic<bool> racing(true); | ||
| 3604 | std::atomic<int> tornReads(0); | ||
| 3605 | std::atomic<int> goodReads(0); | ||
| 3606 | |||
| 3607 | std::thread watcher([&]() { | ||
| 3608 | bool seenOnce = false; | ||
| 3609 | while (racing) { | ||
| 3610 | std::string s; | ||
| 3611 | if (!PayoutTestReadFile(racePath, s)) { | ||
| 3612 | // Only meaningful once the file exists at all. | ||
| 3613 | if (seenOnce) tornReads++; | ||
| 3614 | continue; | ||
| 3615 | } | ||
| 3616 | seenOnce = true; | ||
| 3617 | try { | ||
| 3618 | if (nlohmann::json::parse(s).is_array()) goodReads++; | ||
| 3619 | else tornReads++; | ||
| 3620 | } catch (...) { | ||
| 3621 | tornReads++; | ||
| 3622 | } | ||
| 3623 | } | ||
| 3624 | }); | ||
| 3625 | |||
| 3570 | std::vector<std::thread> threads; | 3626 | std::vector<std::thread> threads; |
| 3571 | for (int t = 0; t < 4; t++) { | 3627 | for (int t = 0; t < kThreads; t++) { |
| 3572 | threads.push_back(std::thread([&, t]() { | 3628 | threads.push_back(std::thread([&]() { |
| 3573 | for (size_t i = 0; i < images.size(); i++) { | 3629 | for (int i = 0; i < kPerThread; i++) { |
| 3574 | size_t idx = (i * 7 + (size_t)t * 13) % images.size(); | 3630 | VersionedBlob v; |
| 3575 | PublishVersioned(racePath, images[idx], raceMutex, | 3631 | v.seq = ++nextSeq; |
| 3576 | racePublished, "publish race selftest"); | 3632 | v.blob = (v.seq & 1) ? tiny : huge; |
| 3633 | PublishVersioned(racePath, v, raceMutex, racePublished, | ||
| 3634 | "publish race selftest"); | ||
| 3577 | } | 3635 | } |
| 3578 | })); | 3636 | })); |
| 3579 | } | 3637 | } |
| 3580 | for (std::thread& th : threads) | 3638 | for (std::thread& th : threads) |
| 3581 | th.join(); | 3639 | th.join(); |
| 3582 | 3640 | racing = false; | |
| 3641 | watcher.join(); | ||
| 3642 | |||
| 3643 | check(goodReads > 0, | ||
| 3644 | "the race test actually observed the file while it was being" | ||
| 3645 | " written"); | ||
| 3646 | check(tornReads == 0, | ||
| 3647 | "four threads publishing the same file never leave it" | ||
| 3648 | " unparseable, at any instant while they are doing it"); | ||
| 3649 | |||
| 3650 | // The highest sequence number issued is the one the file must settle | ||
| 3651 | // on: PublishVersioned refuses anything older, so whichever order the | ||
| 3652 | // writes complete in, that image is the last one that can land. | ||
| 3653 | const std::string& expected = (kTotal & 1) ? tiny : huge; | ||
| 3583 | std::string onDisk; | 3654 | std::string onDisk; |
| 3584 | bool read = PayoutTestReadFile(racePath, onDisk); | 3655 | bool read = PayoutTestReadFile(racePath, onDisk); |
| 3585 | bool parses = false; | 3656 | check(read && onDisk == expected, |
| 3586 | if (read) { | ||
| 3587 | try { parses = nlohmann::json::parse(onDisk).is_array(); } | ||
| 3588 | catch (...) { parses = false; } | ||
| 3589 | } | ||
| 3590 | check(read && parses, | ||
| 3591 | "four threads publishing the same file never leave it unparseable"); | ||
| 3592 | check(read && onDisk == images.back().blob, | ||
| 3593 | "the published file settles on the highest version, whatever the" | 3657 | "the published file settles on the highest version, whatever the" |
| 3594 | " completion order"); | 3658 | " completion order"); |
| 3595 | remove(racePath.c_str()); | 3659 | remove(racePath.c_str()); |