CB-524: make worker placement order deterministic across JVM runs
The weighted policy breaks an exact-weight tie on candidate list order (WeightedRoundRobinPolicy picks the first candidate with a strictly greater score), and that list comes from CompositePeerLauncher.candidates(), which iterates profileConfigs. Both that map and BridgedConfig.workerProfiles() were built with Map.copyOf, whose iteration order is salted per JVM run — so the "in definition order" contract candidates() documents was not held. Two consequences. In production, a config with equal weights (ollama 0.5 / gx10 0.5) placed its first worker on a profile chosen at random on every daemon restart. In the suite, CompositePeerLauncherTest.failoverRetriesNextCandidate- WhenProfileIsUnreachable failed roughly one run in four, because whether profile "a" was tried first depended on the salt. Preserve definition order at every layer: unmodifiable LinkedHashMap for workerProfiles(), profileConfigs, and byProfile (which also feeds the user-visible bridge_profiles listing). The tests build profile maps with an ordered helper rather than Map.of, which is salted for the same reason. Guarded by a pair of tests declaring the same two profiles in opposite order and asserting opposite first attempts, so any order-scrambling implementation must fail one of them. Verified by mutation: reverting profileConfigs to Map.copyOf fails 8/8 runs (6 caught by the original test, 2 only by the new reversed-order one); with the fix, 10/10 fresh JVMs pass, 388 tests green.
This commit is contained in:
@@ -8,6 +8,7 @@ import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -354,7 +355,11 @@ public record BridgedConfig(
|
||||
Map<String, Worker> out = new LinkedHashMap<>();
|
||||
workers.forEach((name, w) -> out.put(name,
|
||||
(w.profile() == null || w.profile().isBlank()) ? w.withProfile(name) : w));
|
||||
return Map.copyOf(out);
|
||||
// Deliberately NOT Map.copyOf: its iteration order is salted per JVM run, which would
|
||||
// discard the YAML definition order built above. Placement tie-breaks on candidate
|
||||
// order (see WeightedRoundRobinPolicy), so losing it makes equal-weight placement
|
||||
// non-reproducible across restarts. Unmodifiable-wrap instead of copy-and-scramble.
|
||||
return Collections.unmodifiableMap(out);
|
||||
}
|
||||
if (worker != null) {
|
||||
String name = (worker.profile() == null || worker.profile().isBlank()) ? "default" : worker.profile();
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -99,7 +100,10 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
}
|
||||
this.delegates = List.copyOf(delegates);
|
||||
this.defaultProfile = defaultProfile;
|
||||
this.profileConfigs = Map.copyOf(profileConfigs);
|
||||
// LinkedHashMap, not Map.copyOf: candidates() promises definition order and the weighted
|
||||
// policy breaks exact-weight ties on it, so a salted iteration order would make placement
|
||||
// differ from one JVM run to the next.
|
||||
this.profileConfigs = Collections.unmodifiableMap(new LinkedHashMap<>(profileConfigs));
|
||||
this.placementPolicy = placementPolicy;
|
||||
this.liveCount = liveCount;
|
||||
Map<String, HerdrPeerLauncher> index = new LinkedHashMap<>();
|
||||
@@ -112,7 +116,8 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.byProfile = Map.copyOf(index);
|
||||
// Order-preserving for the same reason, and because profiles() is user-visible (bridge_profiles).
|
||||
this.byProfile = Collections.unmodifiableMap(index);
|
||||
}
|
||||
|
||||
/** The adapter owning {@code profileName} (null/blank → the default). Throws on an unknown profile. */
|
||||
|
||||
Reference in New Issue
Block a user