feat(util): add SerialJobLane

A FIFO of blocking jobs, run one at a time on a background thread that a
post starts when none is running and that exits once the queue is empty.
Each job returns a completion, and completions run only in pump(), on
the owner's thread. Unlike RPCWorker and AsyncTaskManager nothing ever
joins: abandon() (also run by the destructor) drops queued jobs and
undelivered completions and refuses later posts, and a job stuck in a
blocking call finishes on its own thread. It is meant for backend calls
that can wait out a long scan and may have to be given up. post()
reports a dropped job (abandoned lane, or no thread could be started),
and waitIdle() is bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 296c04386c
commit b2a57a61a0
3 changed files with 222 additions and 0 deletions
+77
View File
@@ -16,6 +16,7 @@
#include "daemon/embedded_daemon.h"
#include "util/connect_stall.h"
#include "util/fetch_backoff.h"
#include "util/serial_job_lane.h"
#include "util/logger.h"
#include "data/transaction_history_cache.h"
#include "data/transaction_history_store_policy.h"
@@ -7978,6 +7979,81 @@ void testLiteWalletControllerShutdownDoesNotHangDuringSync()
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
}
// Tier 4 (g1-lite): SerialJobLane — completions only via pump(), FIFO, abandonable, exception-safe.
void testSerialJobLane()
{
using dragonx::util::SerialJobLane;
{ // completions run only inside pump(), on the pumping thread, in FIFO order
SerialJobLane lane;
std::vector<int> order;
const auto mainId = std::this_thread::get_id();
bool rightThread = true;
for (int i = 0; i < 3; ++i)
EXPECT_TRUE(lane.post([i, &order, &rightThread, mainId]() -> SerialJobLane::Completion {
return [i, &order, &rightThread, mainId]() {
order.push_back(i);
if (std::this_thread::get_id() != mainId) rightThread = false;
};
}));
EXPECT_TRUE(lane.waitIdle(std::chrono::milliseconds(3000)));
EXPECT_TRUE(order.empty());
EXPECT_EQ(lane.pump(), 3);
EXPECT_TRUE(order == std::vector<int>({0, 1, 2}));
EXPECT_TRUE(rightThread);
}
{ // post() never waits for a blocked job; waitIdle is bounded
SerialJobLane lane;
auto gate = std::make_shared<std::atomic<bool>>(true);
const auto t0 = std::chrono::steady_clock::now();
lane.post([gate]() -> SerialJobLane::Completion {
while (gate->load()) std::this_thread::sleep_for(std::chrono::milliseconds(2));
return {};
});
EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500));
EXPECT_TRUE(lane.busy());
EXPECT_FALSE(lane.waitIdle(std::chrono::milliseconds(50)));
gate->store(false);
EXPECT_TRUE(lane.waitIdle(std::chrono::milliseconds(3000)));
}
{ // destroying the lane with a blocked job returns at once; queued job dropped; late completion never runs
auto gate = std::make_shared<std::atomic<bool>>(true);
auto ran = std::make_shared<std::atomic<int>>(0);
auto entered = std::make_shared<std::atomic<bool>>(false);
std::chrono::steady_clock::time_point t0;
{
SerialJobLane lane;
lane.post([gate, ran, entered]() -> SerialJobLane::Completion {
entered->store(true);
while (gate->load()) std::this_thread::sleep_for(std::chrono::milliseconds(2));
ran->fetch_add(1);
return [ran]() { ran->fetch_add(100); };
});
lane.post([ran]() -> SerialJobLane::Completion { ran->fetch_add(10); return {}; });
// The first job must be RUNNING when the lane dies: a job the lane thread hasn't picked up yet is
// dropped unrun like the queued one.
for (int i = 0; i < 400 && !entered->load(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(entered->load());
t0 = std::chrono::steady_clock::now();
}
EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500));
gate->store(false);
for (int i = 0; i < 400 && ran->load() == 0; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
std::this_thread::sleep_for(std::chrono::milliseconds(30));
EXPECT_EQ(ran->load(), 1);
}
{ // a throwing job doesn't kill the lane; post() after abandon() is refused
SerialJobLane lane;
int got = 0;
lane.post([]() -> SerialJobLane::Completion { throw std::runtime_error("boom"); });
lane.post([&got]() -> SerialJobLane::Completion { return [&got]() { got = 7; }; });
EXPECT_TRUE(lane.waitIdle(std::chrono::milliseconds(3000)));
EXPECT_EQ(lane.pump(), 1);
EXPECT_EQ(got, 7);
lane.abandon();
EXPECT_FALSE(lane.post([]() -> SerialJobLane::Completion { return {}; }));
}
}
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
void testLiteRolloutVersionCompare()
{
@@ -13107,6 +13183,7 @@ int main()
testLiteWalletGatewayRefreshSkipsFailedCommand();
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testSerialJobLane();
testLiteRolloutVersionCompare();
testLiteRolloutBucketAndOverrideHelpers();
testLiteRolloutPolicyOverridesAndKillSwitch();