/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.cassandra.db.guardrails;

import java.io.IOException;
import java.net.UnknownHostException;
import java.nio.file.FileStore;
import java.util.Arrays;
import java.util.function.Consumer;

import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimap;

import org.jboss.byteman.contrib.bmunit.BMRule;
import org.jboss.byteman.contrib.bmunit.BMUnitRunner;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;

import org.apache.cassandra.config.DataStorageSpec;
import org.apache.cassandra.db.ConsistencyLevel;
import org.apache.cassandra.db.Directories;
import org.apache.cassandra.gms.ApplicationState;
import org.apache.cassandra.gms.Gossiper;
import org.apache.cassandra.gms.VersionedValue;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.locator.InetAddressAndPort;
import org.apache.cassandra.service.ClientState;
import org.apache.cassandra.service.StorageService;
import org.apache.cassandra.service.disk.usage.DiskUsageBroadcaster;
import org.apache.cassandra.service.disk.usage.DiskUsageMonitor;
import org.apache.cassandra.service.disk.usage.DiskUsageState;
import org.apache.cassandra.utils.FBUtilities;

import static org.apache.cassandra.service.disk.usage.DiskUsageState.FULL;
import static org.apache.cassandra.service.disk.usage.DiskUsageState.NOT_AVAILABLE;
import static org.apache.cassandra.service.disk.usage.DiskUsageState.SPACIOUS;
import static org.apache.cassandra.service.disk.usage.DiskUsageState.STUFFED;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;

/**
 * Tests the guardrails for disk usage, {@link Guardrails#localDataDiskUsage} and {@link Guardrails#replicaDiskUsage}.
 */
@RunWith(BMUnitRunner.class)
@BMRule(name = "Always returns a physical disk size of 1000TiB",
targetClass = "DiskUsageMonitor",
targetMethod = "totalDiskSpace",
action = "return " + (1000L * 1024 * 1024 * 1024 * 1024) + "L;") // 1000TiB
public class GuardrailDiskUsageTest extends GuardrailTester
{
    private static int defaultDataDiskUsagePercentageWarnThreshold;
    private static int defaultDataDiskUsagePercentageFailThreshold;

    @BeforeClass
    public static void beforeClass()
    {
        defaultDataDiskUsagePercentageWarnThreshold = Guardrails.instance.getDataDiskUsagePercentageWarnThreshold();
        defaultDataDiskUsagePercentageFailThreshold = Guardrails.instance.getDataDiskUsagePercentageFailThreshold();

        Guardrails.instance.setDataDiskUsagePercentageThreshold(-1, -1);
    }

    @AfterClass
    public static void afterClass()
    {
        Guardrails.instance.setDataDiskUsagePercentageThreshold(defaultDataDiskUsagePercentageWarnThreshold,
                                                                defaultDataDiskUsagePercentageFailThreshold);
    }

    @Test
    public void testConfigValidation()
    {
        assertConfigValid(x -> x.setDataDiskUsageMaxDiskSize(null));
        assertNull(guardrails().getDataDiskUsageMaxDiskSize());

        assertConfigFails(x -> x.setDataDiskUsageMaxDiskSize("0B"), "0 is not allowed");
        assertConfigFails(x -> x.setDataDiskUsageMaxDiskSize("0KiB"), "0 is not allowed");
        assertConfigFails(x -> x.setDataDiskUsageMaxDiskSize("0MiB"), "0 is not allowed");
        assertConfigFails(x -> x.setDataDiskUsageMaxDiskSize("0GiB"), "0 is not allowed");

        assertConfigValid(x -> x.setDataDiskUsageMaxDiskSize("10B"));
        assertEquals("10B", guardrails().getDataDiskUsageMaxDiskSize());

        assertConfigValid(x -> x.setDataDiskUsageMaxDiskSize("20KiB"));
        assertEquals("20KiB", guardrails().getDataDiskUsageMaxDiskSize());

        assertConfigValid(x -> x.setDataDiskUsageMaxDiskSize("30MiB"));
        assertEquals("30MiB", guardrails().getDataDiskUsageMaxDiskSize());

        assertConfigValid(x -> x.setDataDiskUsageMaxDiskSize("40GiB"));
        assertEquals("40GiB", guardrails().getDataDiskUsageMaxDiskSize());

        long diskSize = DiskUsageMonitor.totalDiskSpace();
        String message = String.format("only %s are actually available on disk", FileUtils.stringifyFileSize(diskSize));
        assertConfigValid(x -> x.setDataDiskUsageMaxDiskSize(diskSize + "B"));
        assertConfigFails(x -> x.setDataDiskUsageMaxDiskSize(diskSize + 1 + "B"), message);
        // We want to test with very big number, Long.MAX_VALUE is not allowed so it was easy to use Intger.MAX_VALUE
        assertConfigFails(x -> x.setDataDiskUsageMaxDiskSize(Integer.MAX_VALUE + "GiB"), message);

        // warn threshold smaller than lower bound
        assertConfigFails(x -> x.setDataDiskUsagePercentageThreshold(0, 80), "0 is not allowed");

        // fail threshold bigger than upper bound
        assertConfigFails(x -> x.setDataDiskUsagePercentageThreshold(1, 110), "maximum allowed value is 100");

        // warn threshold larger than fail threshold
        assertConfigFails(x -> x.setDataDiskUsagePercentageThreshold(60, 50),
                          "The warn threshold 60 for data_disk_usage_percentage_warn_threshold should be lower than the fail threshold 50");
    }

    @Test
    public void testDiskUsageState()
    {
        guardrails().setDataDiskUsagePercentageThreshold(50, 90);

        // under usage
        assertEquals(SPACIOUS, DiskUsageMonitor.instance.getState(10));
        assertEquals(SPACIOUS, DiskUsageMonitor.instance.getState(50));

        // exceed warning threshold
        assertEquals(STUFFED, DiskUsageMonitor.instance.getState(51));
        assertEquals(STUFFED, DiskUsageMonitor.instance.getState(56));
        assertEquals(STUFFED, DiskUsageMonitor.instance.getState(90));

        // exceed fail threshold
        assertEquals(FULL, DiskUsageMonitor.instance.getState(91));
        assertEquals(FULL, DiskUsageMonitor.instance.getState(100));

        // shouldn't be possible to go over 100% but just to be sure
        assertEquals(FULL, DiskUsageMonitor.instance.getState(101));
        assertEquals(FULL, DiskUsageMonitor.instance.getState(500));
    }

    @Test
    public void testDiskUsageDetectorWarnDisabled()
    {
        guardrails().setDataDiskUsagePercentageThreshold(-1, 90);

        // under usage
        assertEquals(SPACIOUS, DiskUsageMonitor.instance.getState(0));
        assertEquals(SPACIOUS, DiskUsageMonitor.instance.getState(50));
        assertEquals(SPACIOUS, DiskUsageMonitor.instance.getState(90));

        // exceed fail threshold
        assertEquals(FULL, DiskUsageMonitor.instance.getState(91));
        assertEquals(FULL, DiskUsageMonitor.instance.getState(100));
    }

    @Test
    public void testDiskUsageDetectorFailDisabled()
    {
        guardrails().setDataDiskUsagePercentageThreshold(50, -1);

        // under usage
        assertEquals(SPACIOUS, DiskUsageMonitor.instance.getState(50));

        // exceed warning threshold
        assertEquals(STUFFED, DiskUsageMonitor.instance.getState(51));
        assertEquals(STUFFED, DiskUsageMonitor.instance.getState(80));
        assertEquals(STUFFED, DiskUsageMonitor.instance.getState(100));
    }

    @Test
    public void testDiskUsageGuardrailDisabled()
    {
        guardrails().setDataDiskUsagePercentageThreshold(-1, -1);

        assertEquals(NOT_AVAILABLE, DiskUsageMonitor.instance.getState(0));
        assertEquals(NOT_AVAILABLE, DiskUsageMonitor.instance.getState(60));
        assertEquals(NOT_AVAILABLE, DiskUsageMonitor.instance.getState(100));
    }

    @Test
    public void testMemtableSizeIncluded() throws Throwable
    {
        DiskUsageMonitor monitor = new DiskUsageMonitor();

        createTable(keyspace(), "CREATE TABLE %s (k text PRIMARY KEY, v text) WITH compression = { 'enabled': false }");

        long memtableSizeBefore = monitor.getAllMemtableSize();
        int rows = 10;
        int mb = 1024 * 1024;

        for (int i = 0; i < rows; i++)
        {
            char[] chars = new char[mb];
            Arrays.fill(chars, (char) i);
            String value = String.copyValueOf(chars);
            execute("INSERT INTO %s (k, v) VALUES(?, ?)", i, value);
        }

        // verify memtables are included
        long memtableSizeAfterInsert = monitor.getAllMemtableSize();
        assertTrue(String.format("Expect at least 10MB more data, but got before: %s and after: %d",
                                 memtableSizeBefore, memtableSizeAfterInsert),
                   memtableSizeAfterInsert - memtableSizeBefore >= rows * mb);

        // verify memtable size are reduced after flush
        flush();
        long memtableSizeAfterFlush = monitor.getAllMemtableSize();
        assertEquals(memtableSizeBefore, memtableSizeAfterFlush, mb);
    }

    @Test
    public void testMonitorLogsOnStateChange()
    {
        guardrails().setDataDiskUsagePercentageThreshold(50, 90);

        Guardrails.localDataDiskUsage.resetLastNotifyTime();

        DiskUsageMonitor monitor = new DiskUsageMonitor();

        // transit to SPACIOUS, no logging
        assertMonitorStateTransition(0.50, SPACIOUS, monitor);

        // transit to STUFFED, expect warning
        assertMonitorStateTransition(0.50001, STUFFED, monitor, true, "Local data disk usage 51%(Stuffed) exceeds warning threshold of 50%");

        // remain as STUFFED, no logging because of min log interval
        assertMonitorStateTransition(0.90, STUFFED, monitor);

        // transit to FULL, expect failure
        assertMonitorStateTransition(0.90001, FULL, monitor, false, "Local data disk usage 91%(Full) exceeds failure threshold of 90%, will stop accepting writes");

        // remain as FULL, no logging because of min log interval
        assertMonitorStateTransition(0.99, FULL, monitor);

        // remain as FULL, no logging because of min log interval
        assertMonitorStateTransition(5.0, FULL, monitor);

        // transit back to STUFFED, no warning  because of min log interval
        assertMonitorStateTransition(0.90, STUFFED, monitor);

        // transit back to FULL, no logging  because of min log interval
        assertMonitorStateTransition(0.900001, FULL, monitor);

        // transit back to STUFFED, no logging  because of min log interval
        assertMonitorStateTransition(0.90, STUFFED, monitor);

        // transit to SPACIOUS, no logging
        assertMonitorStateTransition(0.50, SPACIOUS, monitor);
    }

    @Test
    public void testDiskUsageBroadcaster() throws UnknownHostException
    {
        DiskUsageBroadcaster broadcaster = new DiskUsageBroadcaster(null);
        Gossiper.instance.unregister(broadcaster);

        InetAddressAndPort node1 = InetAddressAndPort.getByName("127.0.0.1");
        InetAddressAndPort node2 = InetAddressAndPort.getByName("127.0.0.2");
        InetAddressAndPort node3 = InetAddressAndPort.getByName("127.0.0.3");

        // initially it's NOT_AVAILABLE
        assertFalse(broadcaster.hasStuffedOrFullNode());
        assertFalse(broadcaster.isFull(node1));
        assertFalse(broadcaster.isFull(node2));
        assertFalse(broadcaster.isFull(node3));

        // adding 1st node: Spacious, cluster has no Full node
        broadcaster.onChange(node1, ApplicationState.DISK_USAGE, value(SPACIOUS));
        assertFalse(broadcaster.hasStuffedOrFullNode());
        assertFalse(broadcaster.isFull(node1));

        // adding 2nd node with wrong ApplicationState
        broadcaster.onChange(node2, ApplicationState.RACK, value(FULL));
        assertFalse(broadcaster.hasStuffedOrFullNode());
        assertFalse(broadcaster.isFull(node2));

        // adding 2nd node: STUFFED
        broadcaster.onChange(node2, ApplicationState.DISK_USAGE, value(STUFFED));
        assertTrue(broadcaster.hasStuffedOrFullNode());
        assertTrue(broadcaster.isStuffed(node2));

        // adding 3rd node: FULL
        broadcaster.onChange(node3, ApplicationState.DISK_USAGE, value(FULL));
        assertTrue(broadcaster.hasStuffedOrFullNode());
        assertTrue(broadcaster.isFull(node3));

        // remove 2nd node, cluster has Full node
        broadcaster.onRemove(node2);
        assertTrue(broadcaster.hasStuffedOrFullNode());
        assertFalse(broadcaster.isStuffed(node2));

        // remove 3nd node, cluster has no Full node
        broadcaster.onRemove(node3);
        assertFalse(broadcaster.hasStuffedOrFullNode());
        assertFalse(broadcaster.isFull(node3));
    }

    @Test
    public void testDiskUsageCalculationWithMaxDiskSize() throws IOException
    {
        Directories.DataDirectory directory = mock(Directories.DataDirectory.class);
        when(directory.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("5GiB").toBytes());

        FileStore store = mock(FileStore.class);
        when(store.getUsableSpace()).thenReturn(new DataStorageSpec.LongBytesBound("95GiB").toBytes()); // 100GiB disk - 5GiB

        Multimap<FileStore, Directories.DataDirectory> directories = HashMultimap.create();
        directories.put(store, directory);
        DiskUsageMonitor monitor = spy(new DiskUsageMonitor(() -> directories));

        doCallRealMethod().when(monitor).getDiskUsage();
        doReturn(0L).when(monitor).getAllMemtableSize();

        guardrails().setDataDiskUsageMaxDiskSize(null);
        assertThat(monitor.getDiskUsage()).isEqualTo(0.05);

        // 5G are used of 10G
        guardrails().setDataDiskUsageMaxDiskSize("10GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(0.5);

        // max disk size = space used
        guardrails().setDataDiskUsageMaxDiskSize("5GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(1.0);

        // max disk size < space used
        guardrails().setDataDiskUsageMaxDiskSize("1GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(5.0);
    }

    @Test
    public void testDiskUsageCalculationWithMaxDiskSizeAndSmallUnits() throws IOException
    {
        // 5GiB used out of 100GiB disk
        long freeDiskSizeInBytes = new DataStorageSpec.LongBytesBound("100GiB").toBytes() - new DataStorageSpec.LongBytesBound("5MiB").toBytes();

        FileStore store = mock(FileStore.class);
        when(store.getUsableSpace()).thenReturn(new DataStorageSpec.LongBytesBound(freeDiskSizeInBytes + "B").toBytes()); // 100GiB disk

        Directories.DataDirectory directory = mock(Directories.DataDirectory.class);
        when(directory.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("5MiB").toBytes());

        Multimap<FileStore, Directories.DataDirectory> directories = HashMultimap.create();
        directories.put(store, directory);
        DiskUsageMonitor monitor = spy(new DiskUsageMonitor(() -> directories));

        doCallRealMethod().when(monitor).getDiskUsage();
        doReturn(0L).when(monitor).getAllMemtableSize();

        guardrails().setDataDiskUsageMaxDiskSize(null);
        assertThat(monitor.getDiskUsage()).isEqualTo(0.00005);

        // 5MiB are used of 10MiB
        guardrails().setDataDiskUsageMaxDiskSize("10MiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(0.5);

        // max disk size = space used
        guardrails().setDataDiskUsageMaxDiskSize("5MiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(1.0);

        // max disk size < space used
        guardrails().setDataDiskUsageMaxDiskSize("1MiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(5.0);
    }

    @Test
    public void testDiskUsageCalculationWithMaxDiskSizeAndMultipleVolumes() throws IOException
    {
        Mockito.reset();

        Multimap<FileStore, Directories.DataDirectory> directories = HashMultimap.create();

        Directories.DataDirectory directory1 = mock(Directories.DataDirectory.class);
        FileStore store1 = mock(FileStore.class);
        when(directory1.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("5GiB").toBytes());
        when(store1.getUsableSpace()).thenReturn(new DataStorageSpec.LongBytesBound("95GiB").toBytes()); // 100 GiB disk - 5 GiB
        directories.put(store1, directory1);

        Directories.DataDirectory directory2 = mock(Directories.DataDirectory.class);
        FileStore store2 = mock(FileStore.class);
        when(directory2.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("25GiB").toBytes());
        when(store2.getUsableSpace()).thenReturn(new DataStorageSpec.LongBytesBound("75GiB").toBytes()); // 100 GiB disk - 25 GiB
        directories.put(store2, directory2);

        Directories.DataDirectory directory3 = mock(Directories.DataDirectory.class);
        FileStore store3 = mock(FileStore.class);
        when(directory3.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("20GiB").toBytes());
        when(store3.getUsableSpace()).thenReturn(new DataStorageSpec.LongBytesBound("80GiB").toBytes()); // 100 GiB disk - 20 GiB
        directories.put(store3, directory3);

        DiskUsageMonitor monitor = spy(new DiskUsageMonitor(() -> directories));

        doCallRealMethod().when(monitor).getDiskUsage();
        doReturn(0L).when(monitor).getAllMemtableSize();

        // 50G/300G as each disk has a capacity of 100G
        guardrails().setDataDiskUsageMaxDiskSize(null);
        assertThat(monitor.getDiskUsage()).isEqualTo(0.16667);

        // 50G/100G
        guardrails().setDataDiskUsageMaxDiskSize("100GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(0.5);

        // 50G/75G
        guardrails().setDataDiskUsageMaxDiskSize("75GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(0.66667);

        // 50G/50G
        guardrails().setDataDiskUsageMaxDiskSize("50GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(1.0);

        // 50G/49G
        guardrails().setDataDiskUsageMaxDiskSize("49GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(1.02041);
    }

    @Test
    public void testDiskUsageCalculationWithMaxDiskSizeAndMultipleDirectories() throws IOException
    {
        Mockito.reset();

        Directories.DataDirectory directory1 = mock(Directories.DataDirectory.class);
        when(directory1.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("5GiB").toBytes());

        Directories.DataDirectory directory2 = mock(Directories.DataDirectory.class);
        when(directory2.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("25GiB").toBytes());

        Directories.DataDirectory directory3 = mock(Directories.DataDirectory.class);
        when(directory3.getRawSize()).thenReturn(new DataStorageSpec.LongBytesBound("20GiB").toBytes());

        FileStore store = mock(FileStore.class);
        when(store.getUsableSpace()).thenReturn(new DataStorageSpec.LongBytesBound("250GiB").toBytes()); // 100 GiB disk (300 - 5 - 25 - 20)

        Multimap<FileStore, Directories.DataDirectory> directories = HashMultimap.create();
        directories.putAll(store, ImmutableSet.of(directory1, directory2, directory3));

        DiskUsageMonitor monitor = spy(new DiskUsageMonitor(() -> directories));

        doCallRealMethod().when(monitor).getDiskUsage();
        doReturn(0L).when(monitor).getAllMemtableSize();

        // 50G/300G as disk has a capacity of 300G
        guardrails().setDataDiskUsageMaxDiskSize(null);
        assertThat(monitor.getDiskUsage()).isEqualTo(0.16667);

        // 50G/100G
        guardrails().setDataDiskUsageMaxDiskSize("100GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(0.5);

        // 50G/75G
        guardrails().setDataDiskUsageMaxDiskSize("75GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(0.66667);

        // 50G/50G
        guardrails().setDataDiskUsageMaxDiskSize("50GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(1.0);

        // 50G/49G
        guardrails().setDataDiskUsageMaxDiskSize("49GiB");
        assertThat(monitor.getDiskUsage()).isEqualTo(1.02041);
    }

    @Test
    public void testWriteRequests() throws Throwable
    {
        createTable("CREATE TABLE %s (k int PRIMARY KEY, v int)");

        InetAddressAndPort local = FBUtilities.getBroadcastAddressAndPort();
        InetAddressAndPort node1 = InetAddressAndPort.getByName("127.0.0.11");
        InetAddressAndPort node2 = InetAddressAndPort.getByName("127.0.0.21");
        InetAddressAndPort node3 = InetAddressAndPort.getByName("127.0.0.31");

        Guardrails.replicaDiskUsage.resetLastNotifyTime();
        guardrails().setDataDiskUsagePercentageThreshold(98, 99);

        ConsistencyLevel cl = ConsistencyLevel.LOCAL_QUORUM;
        String select = "SELECT * FROM %s";
        String insert = "INSERT INTO %s (k, v) VALUES (0, 0)";
        String batch = "BEGIN BATCH " +
                       "INSERT INTO %s (k, v) VALUES (1, 1);" +
                       "INSERT INTO %<s (k, v) VALUES (2, 2); " +
                       "APPLY BATCH";
        CheckedFunction userSelect = () -> execute(userClientState, select, cl);
        CheckedFunction userInsert = () -> execute(userClientState, insert, cl);
        CheckedFunction userBatch = () -> execute(userClientState, batch, cl);

        // default state, write request works fine
        assertValid(userSelect);
        assertValid(userInsert);
        assertValid(userBatch);

        // verify node1 NOT_AVAILABLE won't affect writes
        setDiskUsageState(node1, NOT_AVAILABLE);
        assertValid(userSelect);
        assertValid(userInsert);
        assertValid(userBatch);

        // verify node2 Spacious won't affect writes
        setDiskUsageState(node2, SPACIOUS);
        assertValid(userSelect);
        assertValid(userInsert);
        assertValid(userBatch);

        // verify node3 STUFFED won't trigger warning as it's not write replica
        setDiskUsageState(node3, STUFFED);
        assertValid(userSelect);
        assertValid(userInsert);
        assertValid(userBatch);

        // verify node3 Full won't affect writes as it's not write replica
        setDiskUsageState(node3, FULL);
        assertValid(userSelect);
        assertValid(userInsert);
        assertValid(userBatch);

        // verify local node STUFF, will log warning
        setDiskUsageState(local, STUFFED);
        assertValid(userSelect);
        assertWarns(userInsert);
        assertWarns(userBatch);

        // verify local node Full, will reject writes
        setDiskUsageState(local, FULL);
        assertValid(userSelect);
        assertFails(userInsert);
        assertFails(userBatch);

        // excluded users can write to FULL cluster
        useSuperUser();
        Guardrails.replicaDiskUsage.resetLastNotifyTime();
        for (ClientState excludedUser : Arrays.asList(systemClientState, superClientState))
        {
            assertValid(() -> execute(excludedUser, select, cl));
            assertValid(() -> execute(excludedUser, insert, cl));
            assertValid(() -> execute(excludedUser, batch, cl));
        }

        // verify local node STUFFED won't reject writes
        setDiskUsageState(local, STUFFED);
        assertValid(userSelect);
        assertWarns(userInsert);
        assertWarns(userBatch);
    }

    @Override
    protected void assertValid(CheckedFunction function) throws Throwable
    {
        Guardrails.replicaDiskUsage.resetLastNotifyTime();
        super.assertValid(function);
    }

    protected void assertWarns(CheckedFunction function) throws Throwable
    {
        Guardrails.replicaDiskUsage.resetLastNotifyTime();
        super.assertWarns(function, "Replica disk usage exceeds warning threshold");
    }

    protected void assertFails(CheckedFunction function) throws Throwable
    {
        Guardrails.replicaDiskUsage.resetLastNotifyTime();
        super.assertFails(function, "Write request failed because disk usage exceeds failure threshold");
    }

    private static void setDiskUsageState(InetAddressAndPort endpoint, DiskUsageState state)
    {
        DiskUsageBroadcaster.instance.onChange(endpoint, ApplicationState.DISK_USAGE, value(state));
    }

    private static VersionedValue value(DiskUsageState state)
    {
        return StorageService.instance.valueFactory.diskUsage(state.name());
    }

    private void assertMonitorStateTransition(double usageRatio, DiskUsageState state, DiskUsageMonitor monitor)
    {
        assertMonitorStateTransition(usageRatio, state, monitor, false, null);
    }

    private void assertMonitorStateTransition(double usageRatio, DiskUsageState state, DiskUsageMonitor monitor,
                                              boolean isWarn, String msg)
    {
        boolean stateChanged = state != monitor.state();
        Consumer<DiskUsageState> notifier = newState -> {
            if (stateChanged)
                assertEquals(state, newState);
            else
                fail("Expect no notification if state remains the same");
        };

        monitor.updateLocalState(usageRatio, notifier);
        assertEquals(state, monitor.state());

        if (msg == null)
        {
            listener.assertNotFailed();
            listener.assertNotWarned();
        }
        else if (isWarn)
        {
            listener.assertWarned(msg);
            listener.assertNotFailed();
        }
        else
        {
            listener.assertFailed(msg);
            listener.assertNotWarned();
        }

        listener.clear();
    }
}
