|
| 1 | +// Copyright 2026 Stellar Development Foundation and contributors. Licensed |
| 2 | +// under the Apache License, Version 2.0. See the COPYING file at the root |
| 3 | +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 |
| 4 | + |
| 5 | +// This mission tests the EXPERIMENTAL_TRIGGER_TIMER feature with a mix of |
| 6 | +// nodes that have it enabled vs disabled, under configurable clock drift |
| 7 | +// distributions. It uses generated pubnet topologies (--pubnet-data) and |
| 8 | +// overlays trigger timer and clock offset settings onto the CoreSets. |
| 9 | +// |
| 10 | +// CLI parameters: |
| 11 | +// --trigger-timer-flag-pct N percentage of nodes with the flag (0-100, default 100) |
| 12 | +// --drift-pct N percentage of nodes that drift (0-100, default 0) |
| 13 | +// --uniform-drift=lower,upper uniform random drift in [lower,upper] signed ms (e.g. -2000,+2000) |
| 14 | +// --bimodal-drift=m1,M1,m2,M2 first half in [m1,M1], second half in [m2,M2] signed ms |
| 15 | + |
| 16 | +module MissionTriggerTimerMixConsensus |
| 17 | + |
| 18 | +open Logging |
| 19 | +open StellarCoreHTTP |
| 20 | +open StellarCorePeer |
| 21 | +open StellarCoreSet |
| 22 | +open StellarFormation |
| 23 | +open StellarMissionContext |
| 24 | +open StellarNetworkData |
| 25 | +open StellarStatefulSets |
| 26 | +open StellarSupercluster |
| 27 | + |
| 28 | +type ClockDriftDistribution = |
| 29 | + | NoDrift |
| 30 | + | UniformDrift of lower: int * upper: int |
| 31 | + | BimodalDrift of min1: int * max1: int * min2: int * max2: int |
| 32 | + |
| 33 | +// Round ms to whole seconds, ceiling away from zero: 1500 -> 2, -800 -> -1 |
| 34 | +let private ceilToSec (ms: int) = |
| 35 | + if ms >= 0 then (ms + 999) / 1000 |
| 36 | + else -((abs ms + 999) / 1000) |
| 37 | + |
| 38 | +// Drift suffix for a single offset: 0 -> "", 1500 -> "-p2", -800 -> "-m1" |
| 39 | +let private driftSuffix (ms: int) = |
| 40 | + let s = ceilToSec ms |
| 41 | + if s > 0 then sprintf "-p%d" s |
| 42 | + elif s < 0 then sprintf "-m%d" (abs s) |
| 43 | + else "" |
| 44 | + |
| 45 | +// Build an annotated CoreSet name: append "-expr" if flag enabled, plus drift suffix |
| 46 | +let private annotateName (baseName: string) (flagEnabled: bool) (offsetMs: int) = |
| 47 | + let flagPart = if flagEnabled then "-expr" else "" |
| 48 | + CoreSetName(baseName + flagPart + driftSuffix offsetMs) |
| 49 | + |
| 50 | +let private parseDrift (context: MissionContext) : ClockDriftDistribution = |
| 51 | + match context.uniformDrift, context.bimodalDrift with |
| 52 | + | [], [] -> NoDrift |
| 53 | + | [ lower; upper ], [] -> |
| 54 | + if upper < lower then |
| 55 | + failwith (sprintf "uniform-drift requires lower <= upper, got %d,%d" lower upper) |
| 56 | + UniformDrift(lower, upper) |
| 57 | + | [], [ min1; max1; min2; max2 ] -> |
| 58 | + if max1 < min1 then |
| 59 | + failwith (sprintf "bimodal-drift first range requires min <= max, got %d,%d" min1 max1) |
| 60 | + if max2 < min2 then |
| 61 | + failwith (sprintf "bimodal-drift second range requires min <= max, got %d,%d" min2 max2) |
| 62 | + BimodalDrift(min1, max1, min2, max2) |
| 63 | + | _ :: _, _ :: _ -> failwith "Cannot specify both --uniform-drift and --bimodal-drift" |
| 64 | + | u, [] -> failwith (sprintf "--uniform-drift requires exactly 2 values (lower,upper), got %d" u.Length) |
| 65 | + | [], b -> failwith (sprintf "--bimodal-drift requires exactly 4 values (min1,max1,min2,max2), got %d" b.Length) |
| 66 | + |
| 67 | +let triggerTimerMixConsensus (baseContext: MissionContext) = |
| 68 | + let drift = parseDrift baseContext |
| 69 | + let flagPct = baseContext.triggerTimerFlagPct |
| 70 | + let driftPct = baseContext.driftPct |
| 71 | + |
| 72 | + if flagPct < 0 || flagPct > 100 then |
| 73 | + failwith (sprintf "trigger-timer-flag-pct must be 0-100, got %d" flagPct) |
| 74 | + |
| 75 | + if driftPct < 0 || driftPct > 100 then |
| 76 | + failwith (sprintf "drift-pct must be 0-100, got %d" driftPct) |
| 77 | + |
| 78 | + let context = |
| 79 | + { baseContext with |
| 80 | + numAccounts = 40000 |
| 81 | + numTxs = 90000 |
| 82 | + txRate = 150 |
| 83 | + coreResources = MediumTestResources |
| 84 | + genesisTestAccountCount = Some 40000 |
| 85 | + installNetworkDelay = Some(baseContext.installNetworkDelay |> Option.defaultValue true) |
| 86 | + maxConnections = Some(baseContext.maxConnections |> Option.defaultValue 65) } |
| 87 | + |
| 88 | + let baseCoreSets = FullPubnetCoreSets context true false |
| 89 | + |
| 90 | + let totalNodes = |
| 91 | + List.sumBy (fun (cs: CoreSet) -> cs.options.nodeCount) baseCoreSets |
| 92 | + |
| 93 | + match drift with |
| 94 | + | NoDrift when driftPct > 0 -> |
| 95 | + failwith "drift-pct > 0 but no drift distribution specified (use --uniform-drift or --bimodal-drift)" |
| 96 | + | _ -> () |
| 97 | + |
| 98 | + LogInfo |
| 99 | + "TriggerTimerMixConsensus: %d total nodes, flag-pct=%d%%, drift-pct=%d%%" |
| 100 | + totalNodes |
| 101 | + flagPct |
| 102 | + driftPct |
| 103 | + |
| 104 | + // Each node independently has a flagPct% chance of having the trigger |
| 105 | + // timer flag enabled, and a driftPct% chance of drifting. When drifting, |
| 106 | + // bimodal nodes have a 50/50 chance of being in the first or second group. |
| 107 | + let rng = System.Random(context.randomSeed) |
| 108 | + |
| 109 | + let sampleFlag () = rng.Next(100) < flagPct |
| 110 | + |
| 111 | + let sampleOffset () = |
| 112 | + match drift with |
| 113 | + | NoDrift -> 0 |
| 114 | + | _ when rng.Next(100) >= driftPct -> 0 |
| 115 | + | UniformDrift (lower, upper) -> rng.Next(lower, upper + 1) |
| 116 | + | BimodalDrift (min1, max1, min2, max2) -> |
| 117 | + if rng.Next(2) = 0 then rng.Next(min1, max1 + 1) |
| 118 | + else rng.Next(min2, max2 + 1) |
| 119 | + |
| 120 | + // Walk through CoreSets, splitting each into single-node CoreSets so that |
| 121 | + // each node gets its own name with flag/drift annotation. |
| 122 | + let modifiedCoreSets = |
| 123 | + baseCoreSets |
| 124 | + |> List.collect (fun cs -> |
| 125 | + let nc = cs.options.nodeCount |
| 126 | + |
| 127 | + [ for j in 0 .. nc - 1 do |
| 128 | + let flagEnabled = sampleFlag () |
| 129 | + let offset = sampleOffset () |
| 130 | + |
| 131 | + let baseName = |
| 132 | + if nc > 1 then sprintf "%s-%d" cs.name.StringName j |
| 133 | + else cs.name.StringName |
| 134 | + |
| 135 | + let annotatedName = annotateName baseName flagEnabled offset |
| 136 | + |
| 137 | + LogInfo |
| 138 | + " Node %s: trigger_timer=%b, offset=%d" |
| 139 | + annotatedName.StringName |
| 140 | + flagEnabled |
| 141 | + offset |
| 142 | + |
| 143 | + { cs with |
| 144 | + name = annotatedName |
| 145 | + keys = [| cs.keys.[j] |] |
| 146 | + options = |
| 147 | + { cs.options with |
| 148 | + nodeCount = 1 |
| 149 | + nodeLocs = |
| 150 | + cs.options.nodeLocs |
| 151 | + |> Option.map (fun locs -> [ locs.[j] ]) |
| 152 | + experimentalTriggerTimer = if flagEnabled then Some true else None |
| 153 | + clockOffsets = if offset <> 0 then Some [ offset ] else None } } ]) |
| 154 | + |
| 155 | + let tier1 = |
| 156 | + List.filter (fun (cs: CoreSet) -> cs.options.tier1 = Some true) modifiedCoreSets |
| 157 | + |
| 158 | + let nonTier1 = |
| 159 | + List.filter (fun (cs: CoreSet) -> cs.options.tier1 <> Some true) modifiedCoreSets |
| 160 | + |
| 161 | + context.Execute |
| 162 | + modifiedCoreSets |
| 163 | + None |
| 164 | + (fun (formation: StellarFormation) -> |
| 165 | + formation.WaitUntilConnected modifiedCoreSets |
| 166 | + formation.ManualClose tier1 |
| 167 | + formation.WaitUntilSynced modifiedCoreSets |
| 168 | + |
| 169 | + formation.UpgradeProtocolToLatest tier1 |
| 170 | + formation.UpgradeMaxTxSetSize tier1 (context.txRate * 10) |
| 171 | + |
| 172 | + let loadPeer = |
| 173 | + if nonTier1.Length > 0 then nonTier1.[0] else tier1.[0] |
| 174 | + |
| 175 | + formation.RunLoadgen loadPeer context.GeneratePaymentLoad |
| 176 | + |
| 177 | + formation.CheckNoErrorsAndPairwiseConsistency() |
| 178 | + formation.EnsureAllNodesInSync modifiedCoreSets) |
0 commit comments