private void updateCandidates0(List<T> newCandidates) {
        // clean up existing entries
        for (CandidatesRampingUpEntry<T> entry : rampingUpWindowsMap.values()) {
            entry.candidateAndSteps().clear();
        }
        candidatesFinishedRampingUp.clear();

        // We add the new candidates from this point
        final Object2LongOpenHashMap<T> newCreatedTimestamps = new Object2LongOpenHashMap<>();
        for (T candidate : newCandidates) {
            // Set the cached created timestamps for the next iteration
            final long createTimestamp = computeCreateTimestamp(candidate);
            newCreatedTimestamps.put(candidate, createTimestamp);

            // check if the candidate is already finished ramping up
            final int step = numStep(rampingUpIntervalNanos, ticker, createTimestamp);
            if (step >= totalSteps) {
                candidatesFinishedRampingUp.add(toWeighted(candidate, weightFunction));
                continue;
            }

            // Create a CandidatesRampingUpEntry if there isn't one already
            final long window = windowIndex(createTimestamp);
            if (!rampingUpWindowsMap.containsKey(window)) {
                // align the schedule to execute at the start of each window
                final long initialDelayNanos = initialDelayNanos(window);
                final ScheduledFuture<?> scheduledFuture = executor.scheduleAtFixedRate(
                        () -> updateWeightAndStep(window), initialDelayNanos,
                        rampingUpIntervalNanos, TimeUnit.NANOSECONDS);
                final CandidatesRampingUpEntry<T> entry =
                        new CandidatesRampingUpEntry<>(new HashSet<>(), scheduledFuture);
                rampingUpWindowsMap.put(window, entry);
            }
            final CandidatesRampingUpEntry<T> rampingUpEntry = rampingUpWindowsMap.get(window);

            final CandidateAndStep<T> candidateAndStep =
                    new CandidateAndStep<>(candidate, weightFunction, weightTransition, step, totalSteps);
            rampingUpEntry.addCandidate(candidateAndStep);
        }
        candidateCreatedTimestamps = newCreatedTimestamps;

        buildLoadBalancer();
    }
A ramping up {@link LoadBalancer} which ramps the weight of newly added
candidates using {@link WeightTransition}, {@code rampingUpIntervalMillis} and {@code rampingUpTaskWindow}.
If more than one candidate are added within the {@code rampingUpTaskWindow}, the weights of
them are updated together. If there's already a scheduled job and new candidates are added
within the {@code rampingUpTaskWindow}, they are updated together.
This is an example of how it works when {@code rampingUpTaskWindow} is 500 milliseconds and
{@code rampingUpIntervalMillis} is 2000 milliseconds:
<pre>{@code
----------------------------------------------------------------------------------------------------
    A         B                             C                                       D
    t0        t1                            t2                                      t3         t4
----------------------------------------------------------------------------------------------------
    0ms       t0 + 200ms                    t0 + 1000ms                          t0 + 1800ms  t0 + 2000ms
}</pre>
A and B are ramped up right away when they are added and they are ramped up together at t4.
C is updated alone every 2000 milliseconds. D is ramped up together with A and B at t4.
core/src/main/java/com/linecorp/armeria/common/loadbalancer/RampingUpLoadBalancer.java