Skip to content

Commit 9c436ff

Browse files
sipainstagibbs
andcommitted
txgraph: add fuzz test scenario that avoids cycles inside Trim() (tests)
Trim internally builds an approximate dependency graph of the merged cluster, replacing all existing dependencies within existing clusters with a simple linear chain of dependencies. This helps keep the complexity of the merging operation down, but may result in cycles to appear in the general case, even though in real scenarios (where Trim is called for stitching re-added mempool transactions after a reorg back to the existing mempool transactions) such cycles are not possible. Add a test that specifically targets Trim() but in scenarios where it is guaranteed not to have any cycles. It is a special case, is much more a whitebox test than a blackbox test, and relies on randomness rather than fuzz input. The upside is that somewhat stronger properties can be tested. Co-authored-by: Greg Sanders <[email protected]>
1 parent 938e86f commit 9c436ff

File tree

1 file changed

+134
-7
lines changed

1 file changed

+134
-7
lines changed

src/test/fuzz/txgraph.cpp

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,22 +69,32 @@ struct SimTxGraph
6969
SimTxGraph(SimTxGraph&&) noexcept = default;
7070
SimTxGraph& operator=(SimTxGraph&&) noexcept = default;
7171

72+
/** Get the connected components within this simulated transaction graph. */
73+
std::vector<SetType> GetComponents()
74+
{
75+
auto todo = graph.Positions();
76+
std::vector<SetType> ret;
77+
// Iterate over all connected components of the graph.
78+
while (todo.Any()) {
79+
auto component = graph.FindConnectedComponent(todo);
80+
ret.push_back(component);
81+
todo -= component;
82+
}
83+
return ret;
84+
}
85+
7286
/** Check whether this graph is oversized (contains a connected component whose number of
7387
* transactions exceeds max_cluster_count. */
7488
bool IsOversized()
7589
{
7690
if (!oversized.has_value()) {
7791
// Only recompute when oversized isn't already known.
7892
oversized = false;
79-
auto todo = graph.Positions();
80-
// Iterate over all connected components of the graph.
81-
while (todo.Any()) {
82-
auto component = graph.FindConnectedComponent(todo);
93+
for (auto component : GetComponents()) {
8394
if (component.Count() > max_cluster_count) oversized = true;
8495
uint64_t component_size{0};
8596
for (auto i : component) component_size += graph.FeeRate(i).size;
8697
if (component_size > max_cluster_size) oversized = true;
87-
todo -= component;
8898
}
8999
}
90100
return *oversized;
@@ -287,8 +297,9 @@ FUZZ_TARGET(txgraph)
287297
FuzzedDataProvider provider(buffer.data(), buffer.size());
288298

289299
/** Internal test RNG, used only for decisions which would require significant amount of data
290-
* to be read from the provider, without realistically impacting test sensitivity. */
291-
InsecureRandomContext rng(0xdecade2009added + buffer.size());
300+
* to be read from the provider, without realistically impacting test sensitivity, and for
301+
* specialized test cases that are hard to perform more generically. */
302+
InsecureRandomContext rng(provider.ConsumeIntegral<uint64_t>());
292303

293304
/** Variable used whenever an empty TxGraph::Ref is needed. */
294305
TxGraph::Ref empty_ref;
@@ -830,6 +841,122 @@ FUZZ_TARGET(txgraph)
830841
// else.
831842
assert(top_sim.MatchesOversizedClusters(removed_set));
832843

844+
// Apply all removals to the simulation, and verify the result is no longer
845+
// oversized. Don't query the real graph for oversizedness; it is compared
846+
// against the simulation anyway later.
847+
for (auto simpos : removed_set) {
848+
top_sim.RemoveTransaction(top_sim.GetRef(simpos));
849+
}
850+
assert(!top_sim.IsOversized());
851+
break;
852+
} else if ((block_builders.empty() || sims.size() > 1) &&
853+
top_sim.GetTransactionCount() > max_cluster_count && !top_sim.IsOversized() && command-- == 0) {
854+
// Trim (special case which avoids apparent cycles in the implicit approximate
855+
// dependency graph constructed inside the Trim() implementation). This is worth
856+
// testing separately, because such cycles cannot occur in realistic scenarios,
857+
// but this is hard to replicate in general in this fuzz test.
858+
859+
// First, we need to have dependencies applied and linearizations fixed to avoid
860+
// circular dependencies in implied graph; trigger it via whatever means.
861+
real->CountDistinctClusters({}, false);
862+
863+
// Gather the current clusters.
864+
auto clusters = top_sim.GetComponents();
865+
866+
// Merge clusters randomly until at least one oversized one appears.
867+
bool made_oversized = false;
868+
auto merges_left = clusters.size() - 1;
869+
while (merges_left > 0) {
870+
--merges_left;
871+
// Find positions of clusters in the clusters vector to merge together.
872+
auto par_cl = rng.randrange(clusters.size());
873+
auto chl_cl = rng.randrange(clusters.size() - 1);
874+
chl_cl += (chl_cl >= par_cl);
875+
Assume(chl_cl != par_cl);
876+
// Add between 1 and 3 dependencies between them. As all are in the same
877+
// direction (from the child cluster to parent cluster), no cycles are possible,
878+
// regardless of what internal topology Trim() uses as approximation within the
879+
// clusters.
880+
int num_deps = rng.randrange(3) + 1;
881+
for (int i = 0; i < num_deps; ++i) {
882+
// Find a parent transaction in the parent cluster.
883+
auto par_idx = rng.randrange(clusters[par_cl].Count());
884+
SimTxGraph::Pos par_pos = 0;
885+
for (auto j : clusters[par_cl]) {
886+
if (par_idx == 0) {
887+
par_pos = j;
888+
break;
889+
}
890+
--par_idx;
891+
}
892+
// Find a child transaction in the child cluster.
893+
auto chl_idx = rng.randrange(clusters[chl_cl].Count());
894+
SimTxGraph::Pos chl_pos = 0;
895+
for (auto j : clusters[chl_cl]) {
896+
if (chl_idx == 0) {
897+
chl_pos = j;
898+
break;
899+
}
900+
--chl_idx;
901+
}
902+
// Add dependency to both simulation and real TxGraph.
903+
auto par_ref = top_sim.GetRef(par_pos);
904+
auto chl_ref = top_sim.GetRef(chl_pos);
905+
top_sim.AddDependency(par_ref, chl_ref);
906+
real->AddDependency(*par_ref, *chl_ref);
907+
}
908+
// Compute the combined cluster.
909+
auto par_cluster = clusters[par_cl];
910+
auto chl_cluster = clusters[chl_cl];
911+
auto new_cluster = par_cluster | chl_cluster;
912+
// Remove the parent and child cluster from clusters.
913+
std::erase_if(clusters, [&](const auto& cl) noexcept { return cl == par_cluster || cl == chl_cluster; });
914+
// Add the combined cluster.
915+
clusters.push_back(new_cluster);
916+
// If this is the first merge that causes an oversized cluster to appear, pick
917+
// a random number of further merges to appear.
918+
if (!made_oversized) {
919+
made_oversized = new_cluster.Count() > max_cluster_count;
920+
if (!made_oversized) {
921+
FeeFrac total;
922+
for (auto i : new_cluster) total += top_sim.graph.FeeRate(i);
923+
if (uint32_t(total.size) > max_cluster_size) made_oversized = true;
924+
}
925+
if (made_oversized) merges_left = rng.randrange(clusters.size());
926+
}
927+
}
928+
929+
// Determine an upper bound on how many transactions are removed.
930+
uint32_t max_removed = 0;
931+
for (auto& cluster : clusters) {
932+
// Gather all transaction sizes in the to-be-combined cluster.
933+
std::vector<uint32_t> sizes;
934+
for (auto i : cluster) sizes.push_back(top_sim.graph.FeeRate(i).size);
935+
auto sum_sizes = std::accumulate(sizes.begin(), sizes.end(), uint64_t{0});
936+
// Sort from large to small.
937+
std::sort(sizes.begin(), sizes.end(), std::greater{});
938+
// In the worst case, only the smallest transactions are removed.
939+
while (sizes.size() > max_cluster_count || sum_sizes > max_cluster_size) {
940+
sum_sizes -= sizes.back();
941+
sizes.pop_back();
942+
++max_removed;
943+
}
944+
}
945+
946+
// Invoke Trim now on the definitely-oversized txgraph.
947+
auto removed = real->Trim();
948+
// Verify that the number of removals is within range.
949+
assert(removed.size() >= 1);
950+
assert(removed.size() <= max_removed);
951+
// The removed set must contain all its own descendants.
952+
auto removed_set = top_sim.MakeSet(removed);
953+
for (auto simpos : removed_set) {
954+
assert(top_sim.graph.Descendants(simpos).IsSubsetOf(removed_set));
955+
}
956+
// Something from every oversized cluster should have been removed, and nothing
957+
// else.
958+
assert(top_sim.MatchesOversizedClusters(removed_set));
959+
833960
// Apply all removals to the simulation, and verify the result is no longer
834961
// oversized. Don't query the real graph for oversizedness; it is compared
835962
// against the simulation anyway later.

0 commit comments

Comments
 (0)