SharedRandom.java

/**
 * Copyright (C) 2022 Christopher J. Stehno
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package io.github.cjstehno.testthings.rando;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import lombok.val;

import java.util.Random;
import java.util.random.RandomGenerator;

import static java.lang.Long.parseLong;
import static java.lang.System.getProperty;
import static java.lang.System.nanoTime;

/**
 * A thread-safe "random" number generated similar to the {@link java.util.concurrent.ThreadLocalRandom}, but with the
 * ability to have its seed changed after construction.
 *
 * The ability to know and change the seed at runtime is very useful in allowing for repeatable testing of "random"-based
 * utilities. If you use the same seed, the same values will be generated in the same order.
 *
 * The seed may be injected programmatically, or a system property may be set ("test-tings.rando.seed") which will
 * specify the seed for the whole JVM.
 *
 * <strong>NOTE:</strong> Setting the seed to a known value should ONLY be used for development and testing purposes.
 */
@Slf4j
public class SharedRandom implements RandomGenerator {

    /**
     * The System property which may be used to specify a custom seed value (e.g "test-things.rando.seed").
     */
    public static final String SEED_PROPERTY = "test-things.rando.seed";

    private static ThreadLocal<SharedRandom> SHARED = ThreadLocal.withInitial(() -> {
        log.debug("Creating a new random.");
        return new SharedRandom(null);
    });

    @Getter private long seed;
    private Random random;

    private SharedRandom(final Long seed) {
        reseed(resolveSeed(seed));
    }

    /**
     * Retrieves the singleton instance of the RandomGenerator for the current thread.
     *
     * @return the random generator instance
     */
    public static RandomGenerator current() {
        return SHARED.get();
    }

    @Override public long nextLong() {
        return random.nextLong();
    }

    /**
     * Updates the seed value and rebuilds the internal random generator.
     *
     * @param newSeed the new seed value
     */
    public void reseed(final long newSeed) {
        seed = newSeed;
        random = new Random(newSeed);
        log.debug("Updating seed to {}", newSeed);
    }

    /**
     * Builds a SharedRandom with the specified seed.
     *
     * @param seed the seed
     * @return the SharedRandom instance
     */
    public static SharedRandom generator(final Long seed) {
        return new SharedRandom(seed);
    }

    /**
     * Builds a SharedRandom with the default seed.
     *
     * @return the SharedRandom instance
     */
    public static SharedRandom generator() {
        return generator(null);
    }

    // if a seed is passed, use it, otherwise use configured if exists, then default to current time
    private static long resolveSeed(final Long value) {
        if (value == null) {
            val seedProperty = getProperty(SEED_PROPERTY);
            return seedProperty != null && !seedProperty.isBlank() ? parseLong(seedProperty) : nanoTime();
        }

        if (value < 1) {
            throw new IllegalArgumentException("The seed value must be greater than 0.");
        }

        return value;
    }
}