• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

grpc / grpc-java / #19167

22 Apr 2024 02:48PM UTC coverage: 88.084% (-0.01%) from 88.096%
#19167

push

github

ejona86
util: Remove deactivation and GracefulSwitchLb from MultiChildLb

It is easy to manage these things outside of MultiChildLb and it makes
the shared code easier and use less memory. In particular, we don't want
to use many instances of GracefulSwitchLb in virtually every policy
simply because it was needed in one or two cases.

31195 of 35415 relevant lines covered (88.08%)

0.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.06
/../xds/src/main/java/io/grpc/xds/RingHashLoadBalancer.java
1
/*
2
 * Copyright 2021 The gRPC Authors
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16

17
package io.grpc.xds;
18

19
import static com.google.common.base.Preconditions.checkArgument;
20
import static com.google.common.base.Preconditions.checkNotNull;
21
import static com.google.common.base.Preconditions.checkState;
22
import static io.grpc.ConnectivityState.CONNECTING;
23
import static io.grpc.ConnectivityState.IDLE;
24
import static io.grpc.ConnectivityState.READY;
25
import static io.grpc.ConnectivityState.SHUTDOWN;
26
import static io.grpc.ConnectivityState.TRANSIENT_FAILURE;
27

28
import com.google.common.base.MoreObjects;
29
import com.google.common.collect.HashMultiset;
30
import com.google.common.collect.ImmutableMap;
31
import com.google.common.collect.Multiset;
32
import com.google.common.primitives.UnsignedInteger;
33
import io.grpc.Attributes;
34
import io.grpc.ConnectivityState;
35
import io.grpc.EquivalentAddressGroup;
36
import io.grpc.InternalLogId;
37
import io.grpc.LoadBalancer;
38
import io.grpc.Status;
39
import io.grpc.SynchronizationContext;
40
import io.grpc.util.MultiChildLoadBalancer;
41
import io.grpc.xds.client.XdsLogger;
42
import io.grpc.xds.client.XdsLogger.XdsLogLevel;
43
import java.net.SocketAddress;
44
import java.util.ArrayList;
45
import java.util.Collections;
46
import java.util.HashMap;
47
import java.util.HashSet;
48
import java.util.List;
49
import java.util.Map;
50
import java.util.Set;
51
import java.util.stream.Collectors;
52
import javax.annotation.Nullable;
53

54
/**
55
 * A {@link LoadBalancer} that provides consistent hashing based load balancing to upstream hosts.
56
 * It implements the "Ketama" hashing that maps hosts onto a circle (the "ring") by hashing its
57
 * addresses. Each request is routed to a host by hashing some property of the request and finding
58
 * the nearest corresponding host clockwise around the ring. Each host is placed on the ring some
59
 * number of times proportional to its weight. With the ring partitioned appropriately, the
60
 * addition or removal of one host from a set of N hosts will affect only 1/N requests.
61
 */
62
final class RingHashLoadBalancer extends MultiChildLoadBalancer {
63
  private static final Status RPC_HASH_NOT_FOUND =
1✔
64
      Status.INTERNAL.withDescription("RPC hash not found. Probably a bug because xds resolver"
1✔
65
          + " config selector always generates a hash.");
66
  private static final XxHash64 hashFunc = XxHash64.INSTANCE;
1✔
67

68
  private final LoadBalancer.Factory lazyLbFactory =
1✔
69
      new LazyLoadBalancer.Factory(pickFirstLbProvider);
70
  private final XdsLogger logger;
71
  private final SynchronizationContext syncContext;
72
  private List<RingEntry> ring;
73

74
  RingHashLoadBalancer(Helper helper) {
75
    super(helper);
1✔
76
    syncContext = checkNotNull(helper.getSynchronizationContext(), "syncContext");
1✔
77
    logger = XdsLogger.withLogId(InternalLogId.allocate("ring_hash_lb", helper.getAuthority()));
1✔
78
    logger.log(XdsLogLevel.INFO, "Created");
1✔
79
  }
1✔
80

81
  @Override
82
  public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) {
83
    logger.log(XdsLogLevel.DEBUG, "Received resolution result: {0}", resolvedAddresses);
1✔
84
    List<EquivalentAddressGroup> addrList = resolvedAddresses.getAddresses();
1✔
85
    Status addressValidityStatus = validateAddrList(addrList);
1✔
86
    if (!addressValidityStatus.isOk()) {
1✔
87
      return addressValidityStatus;
1✔
88
    }
89

90
    try {
91
      resolvingAddresses = true;
1✔
92
      // Subclass handles any special manipulation to create appropriate types of ChildLbStates
93
      Map<Object, ChildLbState> newChildren = createChildLbMap(resolvedAddresses);
1✔
94

95
      if (newChildren.isEmpty()) {
1✔
96
        addressValidityStatus = Status.UNAVAILABLE.withDescription(
×
97
            "Ring hash lb error: EDS resolution was successful, but there were no valid addresses");
98
        handleNameResolutionError(addressValidityStatus);
×
99
        return addressValidityStatus;
×
100
      }
101

102
      addMissingChildren(newChildren);
1✔
103
      updateChildrenWithResolvedAddresses(resolvedAddresses, newChildren);
1✔
104

105
      // Now do the ringhash specific logic with weights and building the ring
106
      RingHashConfig config = (RingHashConfig) resolvedAddresses.getLoadBalancingPolicyConfig();
1✔
107
      if (config == null) {
1✔
108
        throw new IllegalArgumentException("Missing RingHash configuration");
×
109
      }
110
      Map<EquivalentAddressGroup, Long> serverWeights = new HashMap<>();
1✔
111
      long totalWeight = 0L;
1✔
112
      for (EquivalentAddressGroup eag : addrList) {
1✔
113
        Long weight = eag.getAttributes().get(InternalXdsAttributes.ATTR_SERVER_WEIGHT);
1✔
114
        // Support two ways of server weighing: either multiple instances of the same address
115
        // or each address contains a per-address weight attribute. If a weight is not provided,
116
        // each occurrence of the address will be counted a weight value of one.
117
        if (weight == null) {
1✔
118
          weight = 1L;
×
119
        }
120
        totalWeight += weight;
1✔
121
        EquivalentAddressGroup addrKey = stripAttrs(eag);
1✔
122
        if (serverWeights.containsKey(addrKey)) {
1✔
123
          serverWeights.put(addrKey, serverWeights.get(addrKey) + weight);
×
124
        } else {
125
          serverWeights.put(addrKey, weight);
1✔
126
        }
127
      }
1✔
128
      // Calculate scale
129
      long minWeight = Collections.min(serverWeights.values());
1✔
130
      double normalizedMinWeight = (double) minWeight / totalWeight;
1✔
131
      // Scale up the number of hashes per host such that the least-weighted host gets a whole
132
      // number of hashes on the the ring. Other hosts might not end up with whole numbers, and
133
      // that's fine (the ring-building algorithm can handle this). This preserves the original
134
      // implementation's behavior: when weights aren't provided, all hosts should get an equal
135
      // number of hashes. In the case where this number exceeds the max_ring_size, it's scaled
136
      // back down to fit.
137
      double scale = Math.min(
1✔
138
          Math.ceil(normalizedMinWeight * config.minRingSize) / normalizedMinWeight,
1✔
139
          (double) config.maxRingSize);
140

141
      // Build the ring
142
      ring = buildRing(serverWeights, totalWeight, scale);
1✔
143

144
      // Must update channel picker before return so that new RPCs will not be routed to deleted
145
      // clusters and resolver can remove them in service config.
146
      updateOverallBalancingState();
1✔
147

148
      shutdownRemoved(getRemovedChildren(newChildren.keySet()));
1✔
149
    } finally {
150
      this.resolvingAddresses = false;
1✔
151
    }
152

153
    return Status.OK;
1✔
154
  }
155

156

157
  /**
158
   * Updates the overall balancing state by aggregating the connectivity states of all subchannels.
159
   *
160
   * <p>Aggregation rules (in order of dominance):
161
   * <ol>
162
   *   <li>If there is at least one subchannel in READY state, overall state is READY</li>
163
   *   <li>If there are <em>2 or more</em> subchannels in TRANSIENT_FAILURE, overall state is
164
   *   TRANSIENT_FAILURE (to allow timely failover to another policy)</li>
165
   *   <li>If there is at least one subchannel in CONNECTING state, overall state is
166
   *   CONNECTING</li>
167
   *   <li> If there is one subchannel in TRANSIENT_FAILURE state and there is
168
   *    more than one subchannel, report CONNECTING </li>
169
   *   <li>If there is at least one subchannel in IDLE state, overall state is IDLE</li>
170
   *   <li>Otherwise, overall state is TRANSIENT_FAILURE</li>
171
   * </ol>
172
   */
173
  @Override
174
  protected void updateOverallBalancingState() {
175
    checkState(!getChildLbStates().isEmpty(), "no subchannel has been created");
1✔
176
    if (this.currentConnectivityState == SHUTDOWN) {
1✔
177
      // Ignore changes that happen after shutdown is called
178
      logger.log(XdsLogLevel.DEBUG, "UpdateOverallBalancingState called after shutdown");
×
179
      return;
×
180
    }
181

182
    // Calculate the current overall state to report
183
    int numIdle = 0;
1✔
184
    int numReady = 0;
1✔
185
    int numConnecting = 0;
1✔
186
    int numTF = 0;
1✔
187

188
    forloop:
189
    for (ChildLbState childLbState : getChildLbStates()) {
1✔
190
      ConnectivityState state = childLbState.getCurrentState();
1✔
191
      switch (state) {
1✔
192
        case READY:
193
          numReady++;
1✔
194
          break forloop;
1✔
195
        case CONNECTING:
196
          numConnecting++;
1✔
197
          break;
1✔
198
        case IDLE:
199
          numIdle++;
1✔
200
          break;
1✔
201
        case TRANSIENT_FAILURE:
202
          numTF++;
1✔
203
          break;
1✔
204
        default:
205
          // ignore it
206
      }
207
    }
1✔
208

209
    ConnectivityState overallState;
210
    if (numReady > 0) {
1✔
211
      overallState = READY;
1✔
212
    } else if (numTF >= 2) {
1✔
213
      overallState = TRANSIENT_FAILURE;
1✔
214
    } else if (numConnecting > 0) {
1✔
215
      overallState = CONNECTING;
1✔
216
    } else if (numTF == 1 && getChildLbStates().size() > 1) {
1✔
217
      overallState = CONNECTING;
1✔
218
    } else if (numIdle > 0) {
1✔
219
      overallState = IDLE;
1✔
220
    } else {
221
      overallState = TRANSIENT_FAILURE;
×
222
    }
223

224
    RingHashPicker picker = new RingHashPicker(syncContext, ring, getImmutableChildMap());
1✔
225
    getHelper().updateBalancingState(overallState, picker);
1✔
226
    this.currentConnectivityState = overallState;
1✔
227
  }
1✔
228

229
  @Override
230
  protected ChildLbState createChildLbState(Object key, Object policyConfig,
231
      SubchannelPicker initialPicker, ResolvedAddresses resolvedAddresses) {
232
    return new RingHashChildLbState((Endpoint)key);
1✔
233
  }
234

235
  private Status validateAddrList(List<EquivalentAddressGroup> addrList) {
236
    if (addrList.isEmpty()) {
1✔
237
      Status unavailableStatus = Status.UNAVAILABLE.withDescription("Ring hash lb error: EDS "
×
238
              + "resolution was successful, but returned server addresses are empty.");
239
      handleNameResolutionError(unavailableStatus);
×
240
      return unavailableStatus;
×
241
    }
242

243
    String dupAddrString = validateNoDuplicateAddresses(addrList);
1✔
244
    if (dupAddrString != null) {
1✔
245
      Status unavailableStatus = Status.UNAVAILABLE.withDescription("Ring hash lb error: EDS "
1✔
246
              + "resolution was successful, but there were duplicate addresses: " + dupAddrString);
247
      handleNameResolutionError(unavailableStatus);
1✔
248
      return unavailableStatus;
1✔
249
    }
250

251
    long totalWeight = 0;
1✔
252
    for (EquivalentAddressGroup eag : addrList) {
1✔
253
      Long weight = eag.getAttributes().get(InternalXdsAttributes.ATTR_SERVER_WEIGHT);
1✔
254

255
      if (weight == null) {
1✔
256
        weight = 1L;
×
257
      }
258

259
      if (weight < 0) {
1✔
260
        Status unavailableStatus = Status.UNAVAILABLE.withDescription(
1✔
261
            String.format("Ring hash lb error: EDS resolution was successful, but returned a "
1✔
262
                        + "negative weight for %s.", stripAttrs(eag)));
1✔
263
        handleNameResolutionError(unavailableStatus);
1✔
264
        return unavailableStatus;
1✔
265
      }
266
      if (weight > UnsignedInteger.MAX_VALUE.longValue()) {
1✔
267
        Status unavailableStatus = Status.UNAVAILABLE.withDescription(
1✔
268
            String.format("Ring hash lb error: EDS resolution was successful, but returned a weight"
1✔
269
                + " too large to fit in an unsigned int for %s.", stripAttrs(eag)));
1✔
270
        handleNameResolutionError(unavailableStatus);
1✔
271
        return unavailableStatus;
1✔
272
      }
273
      totalWeight += weight;
1✔
274
    }
1✔
275

276
    if (totalWeight > UnsignedInteger.MAX_VALUE.longValue()) {
1✔
277
      Status unavailableStatus = Status.UNAVAILABLE.withDescription(
1✔
278
          String.format(
1✔
279
              "Ring hash lb error: EDS resolution was successful, but returned a sum of weights too"
280
                  + " large to fit in an unsigned int (%d).", totalWeight));
1✔
281
      handleNameResolutionError(unavailableStatus);
1✔
282
      return unavailableStatus;
1✔
283
    }
284

285
    return Status.OK;
1✔
286
  }
287

288
  @Nullable
289
  private String validateNoDuplicateAddresses(List<EquivalentAddressGroup> addrList) {
290
    Set<SocketAddress> addresses = new HashSet<>();
1✔
291
    Multiset<String> dups = HashMultiset.create();
1✔
292
    for (EquivalentAddressGroup eag : addrList) {
1✔
293
      for (SocketAddress address : eag.getAddresses()) {
1✔
294
        if (!addresses.add(address)) {
1✔
295
          dups.add(address.toString());
1✔
296
        }
297
      }
1✔
298
    }
1✔
299

300
    if (!dups.isEmpty()) {
1✔
301
      return dups.entrySet().stream()
1✔
302
          .map((dup) ->
1✔
303
              String.format("Address: %s, count: %d", dup.getElement(), dup.getCount() + 1))
1✔
304
          .collect(Collectors.joining("; "));
1✔
305
    }
306

307
    return null;
1✔
308
  }
309

310
  private static List<RingEntry> buildRing(
311
      Map<EquivalentAddressGroup, Long> serverWeights, long totalWeight, double scale) {
312
    List<RingEntry> ring = new ArrayList<>();
1✔
313
    double currentHashes = 0.0;
1✔
314
    double targetHashes = 0.0;
1✔
315
    for (Map.Entry<EquivalentAddressGroup, Long> entry : serverWeights.entrySet()) {
1✔
316
      Endpoint endpoint = new Endpoint(entry.getKey());
1✔
317
      double normalizedWeight = (double) entry.getValue() / totalWeight;
1✔
318
      // Per GRFC A61 use the first address for the hash
319
      StringBuilder sb = new StringBuilder(entry.getKey().getAddresses().get(0).toString());
1✔
320
      sb.append('_');
1✔
321
      int lengthWithoutCounter = sb.length();
1✔
322
      targetHashes += scale * normalizedWeight;
1✔
323
      long i = 0L;
1✔
324
      while (currentHashes < targetHashes) {
1✔
325
        sb.append(i);
1✔
326
        long hash = hashFunc.hashAsciiString(sb.toString());
1✔
327
        ring.add(new RingEntry(hash, endpoint));
1✔
328
        i++;
1✔
329
        currentHashes++;
1✔
330
        sb.setLength(lengthWithoutCounter);
1✔
331
      }
1✔
332
    }
1✔
333
    Collections.sort(ring);
1✔
334
    return Collections.unmodifiableList(ring);
1✔
335
  }
336

337
  @SuppressWarnings("ReferenceEquality")
338
  public static EquivalentAddressGroup stripAttrs(EquivalentAddressGroup eag) {
339
    if (eag.getAttributes() == Attributes.EMPTY) {
1✔
340
      return eag;
×
341
    }
342
    return new EquivalentAddressGroup(eag.getAddresses());
1✔
343
  }
344

345
  private static final class RingHashPicker extends SubchannelPicker {
346
    private final SynchronizationContext syncContext;
347
    private final List<RingEntry> ring;
348
    // Avoid synchronization between pickSubchannel and subchannel's connectivity state change,
349
    // freeze picker's view of subchannel's connectivity state.
350
    // TODO(chengyuanzhang): can be more performance-friendly with
351
    //  IdentityHashMap<Subchannel, ConnectivityStateInfo> and RingEntry contains Subchannel.
352
    private final Map<Endpoint, SubchannelView> pickableSubchannels;  // read-only
353

354
    private RingHashPicker(
355
        SynchronizationContext syncContext, List<RingEntry> ring,
356
        ImmutableMap<Object, ChildLbState> subchannels) {
1✔
357
      this.syncContext = syncContext;
1✔
358
      this.ring = ring;
1✔
359
      pickableSubchannels = new HashMap<>(subchannels.size());
1✔
360
      for (Map.Entry<Object, ChildLbState> entry : subchannels.entrySet()) {
1✔
361
        RingHashChildLbState childLbState = (RingHashChildLbState) entry.getValue();
1✔
362
        pickableSubchannels.put((Endpoint)entry.getKey(),
1✔
363
            new SubchannelView(childLbState, childLbState.getCurrentState()));
1✔
364
      }
1✔
365
    }
1✔
366

367
    // Find the ring entry with hash next to (clockwise) the RPC's hash (binary search).
368
    private int getTargetIndex(Long requestHash) {
369
      if (ring.size() <= 1) {
1✔
370
        return 0;
×
371
      }
372

373
      int low = 0;
1✔
374
      int high = ring.size() - 1;
1✔
375
      int mid = (low + high) / 2;
1✔
376
      do {
377
        long midVal = ring.get(mid).hash;
1✔
378
        long midValL = mid == 0 ? 0 : ring.get(mid - 1).hash;
1✔
379
        if (requestHash <= midVal && requestHash > midValL) {
1✔
380
          break;
1✔
381
        }
382
        if (midVal < requestHash) {
1✔
383
          low = mid + 1;
1✔
384
        } else {
385
          high =  mid - 1;
1✔
386
        }
387
        mid = (low + high) / 2;
1✔
388
      } while (mid < ring.size() && low <= high);
1✔
389
      return mid;
1✔
390
    }
391

392
    @Override
393
    public PickResult pickSubchannel(PickSubchannelArgs args) {
394
      Long requestHash = args.getCallOptions().getOption(XdsNameResolver.RPC_HASH_KEY);
1✔
395
      if (requestHash == null) {
1✔
396
        return PickResult.withError(RPC_HASH_NOT_FOUND);
×
397
      }
398

399
      int targetIndex = getTargetIndex(requestHash);
1✔
400

401
      // Per gRFC A61, because of sticky-TF with PickFirst's auto reconnect on TF, we ignore
402
      // all TF subchannels and find the first ring entry in READY, CONNECTING or IDLE.  If
403
      // CONNECTING or IDLE we return a pick with no results.  Additionally, if that entry is in
404
      // IDLE, we initiate a connection.
405
      for (int i = 0; i < ring.size(); i++) {
1✔
406
        int index = (targetIndex + i) % ring.size();
1✔
407
        SubchannelView subchannelView = pickableSubchannels.get(ring.get(index).addrKey);
1✔
408
        RingHashChildLbState childLbState = subchannelView.childLbState;
1✔
409

410
        if (subchannelView.connectivityState  == READY) {
1✔
411
          return childLbState.getCurrentPicker().pickSubchannel(args);
1✔
412
        }
413

414
        // RPCs can be buffered if the next subchannel is pending (per A62). Otherwise, RPCs
415
        // are failed unless there is a READY connection.
416
        if (subchannelView.connectivityState == CONNECTING) {
1✔
417
          return PickResult.withNoResult();
1✔
418
        }
419

420
        if (subchannelView.connectivityState == IDLE) {
1✔
421
          syncContext.execute(() -> {
1✔
422
            childLbState.getLb().requestConnection();
1✔
423
          });
1✔
424

425
          return PickResult.withNoResult(); // Indicates that this should be retried after backoff
1✔
426
        }
427
      }
428

429
      // return the pick from the original subchannel hit by hash, which is probably an error
430
      RingHashChildLbState originalSubchannel =
1✔
431
          pickableSubchannels.get(ring.get(targetIndex).addrKey).childLbState;
1✔
432
      return originalSubchannel.getCurrentPicker().pickSubchannel(args);
1✔
433
    }
434

435
  }
436

437
  /**
438
   * An unmodifiable view of a subchannel with state not subject to its real connectivity
439
   * state changes.
440
   */
441
  private static final class SubchannelView {
442
    private final RingHashChildLbState childLbState;
443
    private final ConnectivityState connectivityState;
444

445
    private SubchannelView(RingHashChildLbState childLbState, ConnectivityState state) {
1✔
446
      this.childLbState = childLbState;
1✔
447
      this.connectivityState = state;
1✔
448
    }
1✔
449
  }
450

451
  private static final class RingEntry implements Comparable<RingEntry> {
452
    private final long hash;
453
    private final Endpoint addrKey;
454

455
    private RingEntry(long hash, Endpoint addrKey) {
1✔
456
      this.hash = hash;
1✔
457
      this.addrKey = addrKey;
1✔
458
    }
1✔
459

460
    @Override
461
    public int compareTo(RingEntry entry) {
462
      return Long.compare(hash, entry.hash);
1✔
463
    }
464
  }
465

466
  /**
467
   * Configures the ring property. The larger the ring is (that is, the more hashes there are
468
   * for each provided host) the better the request distribution will reflect the desired weights.
469
   */
470
  static final class RingHashConfig {
471
    final long minRingSize;
472
    final long maxRingSize;
473

474
    RingHashConfig(long minRingSize, long maxRingSize) {
1✔
475
      checkArgument(minRingSize > 0, "minRingSize <= 0");
1✔
476
      checkArgument(maxRingSize > 0, "maxRingSize <= 0");
1✔
477
      checkArgument(minRingSize <= maxRingSize, "minRingSize > maxRingSize");
1✔
478
      this.minRingSize = minRingSize;
1✔
479
      this.maxRingSize = maxRingSize;
1✔
480
    }
1✔
481

482
    @Override
483
    public String toString() {
484
      return MoreObjects.toStringHelper(this)
×
485
          .add("minRingSize", minRingSize)
×
486
          .add("maxRingSize", maxRingSize)
×
487
          .toString();
×
488
    }
489
  }
490

491
  class RingHashChildLbState extends MultiChildLoadBalancer.ChildLbState {
492

493
    public RingHashChildLbState(Endpoint key) {
1✔
494
      super(key, lazyLbFactory, null, EMPTY_PICKER);
1✔
495
    }
1✔
496

497
    @Override
498
    protected ChildLbStateHelper createChildHelper() {
499
      return new RingHashChildHelper();
1✔
500
    }
501

502
    // Need to expose this to the LB class
503
    @Override
504
    protected void shutdown() {
505
      super.shutdown();
1✔
506
    }
1✔
507

508
    private class RingHashChildHelper extends ChildLbStateHelper {
1✔
509
      @Override
510
      public void updateBalancingState(final ConnectivityState newState,
511
                                       final SubchannelPicker newPicker) {
512
        setCurrentState(newState);
1✔
513
        setCurrentPicker(newPicker);
1✔
514
        
515
        if (getChildLbState(getKey()) == null) {
1✔
516
          return;
×
517
        }
518

519
        // If we are already in the process of resolving addresses, the overall balancing state
520
        // will be updated at the end of it, and we don't need to trigger that update here.
521
        if (!resolvingAddresses) {
1✔
522
          updateOverallBalancingState();
1✔
523
        }
524
      }
1✔
525
    }
526
  }
527
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc