All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opensearch.cluster.coordination.LinearizabilityChecker Maven / Gradle / Ivy

There is a newer version: 2.18.0
Show newest version
/*
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 */

/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch 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.
 */
/*
 * Modifications Copyright OpenSearch Contributors. See
 * GitHub history for details.
 */

package org.opensearch.cluster.coordination;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.lucene.util.FixedBitSet;
import org.opensearch.common.collect.Tuple;
import org.opensearch.core.common.Strings;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;

/**
 * Basic implementation of the Wing and Gong Graph Search Algorithm, following the descriptions in
 * Gavin Lowe: Testing for linearizability
 *   Concurrency and Computation: Practice and Experience 29, 4 (2017). http://dx.doi.org/10.1002/cpe.3928
 * Alex Horn and Daniel Kroening: Faster linearizability checking via P-compositionality
 *   FORTE (2015). http://dx.doi.org/10.1007/978-3-319-19195-9_4
 */
public class LinearizabilityChecker {

    private static final Logger logger = LogManager.getLogger(LinearizabilityChecker.class);

    /**
     * Sequential specification of a datatype. Used as input for the linearizability checker.
     * All parameter and return values should be immutable and have proper equals / hashCode implementations
     */
    public interface SequentialSpec {
        /**
         * Returns the initial state of the datatype
         */
        Object initialState();

        /**
         * Next-state function, checking whether transitioning the datatype in the given state under the provided input and output is valid.
         *
         * @param currentState the current state of the datatype
         * @param input the input, associated with the given invocation event
         * @param output the output, associated with the corresponding response event
         * @return the next state, if the given current state, input and output are a valid transition, or Optional.empty() otherwise
         */
        Optional nextState(Object currentState, Object input, Object output);

        /**
         * For compositional checking, the history can be partitioned into sub-histories
         *
         * @param events the history of events to partition
         * @return the partitioned history
         */
        default Collection> partition(List events) {
            return Collections.singleton(events);
        }
    }

    /**
     * Sequential specification of a datatype that allows for keyed access,
     * providing compositional checking (see {@link SequentialSpec#partition(List)}).
     */
    public interface KeyedSpec extends SequentialSpec {
        /**
         * extracts the key from the given keyed invocation input value
         */
        Object getKey(Object value);

        /**
         * extracts the key-less value from the given keyed invocation input value
         */
        Object getValue(Object value);

        @Override
        default Collection> partition(List events) {
            final Map> keyedPartitions = new HashMap<>();
            final Map matches = new HashMap<>();
            for (Event event : events) {
                if (event.type == EventType.INVOCATION) {
                    final Object key = getKey(event.value);
                    final Object val = getValue(event.value);
                    final Event unfoldedEvent = new Event(EventType.INVOCATION, val, event.id);
                    keyedPartitions.computeIfAbsent(key, k -> new ArrayList<>()).add(unfoldedEvent);
                    matches.put(event.id, key);
                } else {
                    final Object key = matches.get(event.id);
                    keyedPartitions.get(key).add(event);
                }
            }
            return keyedPartitions.values();
        }
    }

    /**
     * Sequence of invocations and responses, recording the run of a concurrent system.
     */
    public static class History {
        private final Queue events;
        private AtomicInteger nextId = new AtomicInteger();

        public History() {
            events = new ConcurrentLinkedQueue<>();
        }

        public History(Collection events) {
            this();
            this.events.addAll(events);
            this.nextId.set(events.stream().mapToInt(e -> e.id).max().orElse(-1) + 1);
        }

        /**
         * Appends a new invocation event to the history
         *
         * @param input the input value associated with the invocation event
         * @return an id that can be used to record the corresponding response event
         */
        public int invoke(Object input) {
            final int id = nextId.getAndIncrement();
            events.add(new Event(EventType.INVOCATION, input, id));
            return id;
        }

        /**
         * Appends a new response event to the history
         *
         * @param id the id of the corresponding invocation event
         * @param output the output value associated with the response event
         */
        public void respond(int id, Object output) {
            events.add(new Event(EventType.RESPONSE, output, id));
        }

        /**
         * Removes the events with the corresponding id from the history
         *
         * @param id the value of the id to remove
         */
        public void remove(int id) {
            events.removeIf(e -> e.id == id);
        }

        /**
         * Copy the list of events for external use.
         * @return list of events in the order recorded.
         */
        public List copyEvents() {
            return new ArrayList<>(events);
        }

        /**
         * Completes the history with response events for invocations that are missing corresponding responses
         *
         * @param missingResponseGenerator a function from invocation input to response output, used to generate the corresponding response
         */
        public void complete(Function missingResponseGenerator) {
            final Map uncompletedInvocations = new HashMap<>();
            for (Event event : events) {
                if (event.type == EventType.INVOCATION) {
                    uncompletedInvocations.put(event.id, event);
                } else {
                    final Event removed = uncompletedInvocations.remove(event.id);
                    if (removed == null) {
                        throw new IllegalArgumentException("history not well-formed: " + events);
                    }
                }
            }
            for (Map.Entry entry : uncompletedInvocations.entrySet()) {
                events.add(new Event(EventType.RESPONSE, missingResponseGenerator.apply(entry.getValue().value), entry.getKey()));
            }
        }

        @Override
        public History clone() {
            return new History(events);
        }

        /**
         * Returns the number of recorded events
         */
        public int size() {
            return events.size();
        }

        @Override
        public String toString() {
            return "History{" + "events=" + events + ", nextId=" + nextId + '}';
        }

    }

    /**
     * Checks whether the provided history is linearizable with respect to the given sequential specification
     *
     * @param spec the sequential specification of the datatype
     * @param history the history of events to check for linearizability
     * @param missingResponseGenerator used to complete the history with missing responses
     * @return true iff the history is linearizable w.r.t. the given spec
     */
    public boolean isLinearizable(SequentialSpec spec, History history, Function missingResponseGenerator) {
        return isLinearizable(spec, history, missingResponseGenerator, () -> false);
    }

    /**
     * Checks whether the provided history is linearizable with respect to the given sequential specification
     *
     * @param spec the sequential specification of the datatype
     * @param history the history of events to check for linearizability
     * @param missingResponseGenerator used to complete the history with missing responses
     * @param terminateEarly a condition upon which to terminate early
     * @return true iff the history is linearizable w.r.t. the given spec
     */
    public boolean isLinearizable(
        SequentialSpec spec,
        History history,
        Function missingResponseGenerator,
        BooleanSupplier terminateEarly
    ) {
        history = history.clone(); // clone history before completing it
        history.complete(missingResponseGenerator); // complete history
        final Collection> partitions = spec.partition(history.copyEvents());
        return partitions.stream().allMatch(h -> isLinearizable(spec, h, terminateEarly));
    }

    private boolean isLinearizable(SequentialSpec spec, List history, BooleanSupplier terminateEarly) {
        logger.debug("Checking history of size: {}: {}", history.size(), history);
        Object state = spec.initialState(); // the current state of the datatype
        final FixedBitSet linearized = new FixedBitSet(history.size() / 2); // the linearized prefix of the history

        final Cache cache = new Cache(); // cache of explored  pairs
        final Deque> calls = new LinkedList<>(); // path we're currently exploring

        final Entry headEntry = createLinkedEntries(history);
        Entry entry = headEntry.next; // current entry

        while (headEntry.next != null) {
            if (terminateEarly.getAsBoolean()) {
                return false;
            }
            if (entry.match != null) {
                final Optional maybeNextState = spec.nextState(state, entry.event.value, entry.match.event.value);
                boolean shouldExploreNextState = false;
                if (maybeNextState.isPresent()) {
                    // check if we have already explored this linearization
                    final FixedBitSet updatedLinearized = linearized.clone();
                    updatedLinearized.set(entry.id);
                    shouldExploreNextState = cache.add(maybeNextState.get(), updatedLinearized);
                }
                if (shouldExploreNextState) {
                    calls.push(new Tuple<>(entry, state));
                    state = maybeNextState.get();
                    linearized.set(entry.id);
                    entry.lift();
                    entry = headEntry.next;
                } else {
                    entry = entry.next;
                }
            } else {
                if (calls.isEmpty()) {
                    return false;
                }
                final Tuple top = calls.pop();
                entry = top.v1();
                state = top.v2();
                linearized.clear(entry.id);
                entry.unlift();
                entry = entry.next;
            }
        }
        return true;
    }

    /**
     * Convenience method for {@link #isLinearizable(SequentialSpec, History, Function)} that requires the history to be complete
     */
    public boolean isLinearizable(SequentialSpec spec, History history) {
        return isLinearizable(spec, history, o -> { throw new IllegalArgumentException("history is not complete"); });
    }

    /**
     * Return a visual representation of the history
     */
    public static String visualize(SequentialSpec spec, History history, Function missingResponseGenerator) {
        history = history.clone();
        history.complete(missingResponseGenerator);
        final Collection> partitions = spec.partition(history.copyEvents());
        StringBuilder builder = new StringBuilder();
        partitions.forEach(new Consumer>() {
            int index = 0;

            @Override
            public void accept(List events) {
                builder.append("Partition ").append(index++).append("\n");
                builder.append(visualizePartition(events));
            }
        });

        return builder.toString();
    }

    private static String visualizePartition(List events) {
        StringBuilder builder = new StringBuilder();
        Entry entry = createLinkedEntries(events).next;
        Map, Integer> eventToPosition = new HashMap<>();
        for (Event event : events) {
            eventToPosition.put(Tuple.tuple(event.type, event.id), eventToPosition.size());
        }
        while (entry != null) {
            if (entry.match != null) {
                builder.append(visualizeEntry(entry, eventToPosition)).append("\n");
            }
            entry = entry.next;
        }
        return builder.toString();
    }

    private static String visualizeEntry(Entry entry, Map, Integer> eventToPosition) {
        String input = String.valueOf(entry.event.value);
        String output = String.valueOf(entry.match.event.value);
        int id = entry.event.id;
        int beginIndex = eventToPosition.get(Tuple.tuple(EventType.INVOCATION, id));
        int endIndex = eventToPosition.get(Tuple.tuple(EventType.RESPONSE, id));
        input = input.substring(0, Math.min(beginIndex + 25, input.length()));
        return Strings.padStart(input, beginIndex + 25, ' ')
            + "   "
            + Strings.padStart("", endIndex - beginIndex, 'X')
            + "   "
            + output
            + "  ("
            + entry.event.id
            + ")";
    }

    /**
     * Creates the internal linked data structure used by the linearizability checker.
     * Generates contiguous internal ids for the events so that they can be efficiently recorded in bit sets.
     */
    private static Entry createLinkedEntries(List history) {
        if (history.size() % 2 != 0) {
            throw new IllegalArgumentException("mismatch between number of invocations and responses");
        }

        // first, create entries and link response events to invocation events
        final Map matches = new HashMap<>(); // map from event id to matching response entry
        final Entry[] entries = new Entry[history.size()];
        int nextInternalId = (history.size() / 2) - 1;
        for (int i = history.size() - 1; i >= 0; i--) {
            final Event elem = history.get(i);
            if (elem.type == EventType.RESPONSE) {
                final Entry entry = entries[i] = new Entry(elem, null, nextInternalId--);
                final Entry prev = matches.put(elem.id, entry);
                if (prev != null) {
                    throw new IllegalArgumentException("duplicate response with id " + elem.id);
                }
            } else {
                final Entry matchingResponse = matches.get(elem.id);
                if (matchingResponse == null) {
                    throw new IllegalArgumentException("no matching response found for " + elem);
                }
                entries[i] = new Entry(elem, matchingResponse, matchingResponse.id);
            }
        }

        // sanity check
        if (nextInternalId != -1) {
            throw new IllegalArgumentException("id mismatch");
        }

        // now link entries together in history order, and add a sentinel node at the beginning
        Entry first = new Entry(null, null, -1);
        Entry lastEntry = first;
        for (Entry entry : entries) {
            lastEntry.next = entry;
            entry.prev = lastEntry;
            lastEntry = entry;
        }

        return first;
    }

    public enum EventType {
        INVOCATION,
        RESPONSE
    }

    public static class Event {
        public final EventType type;
        public final Object value;
        public final int id;

        public Event(EventType type, Object value, int id) {
            this.type = type;
            this.value = value;
            this.id = id;
        }

        @Override
        public String toString() {
            return "Event{" + "type=" + type + ", value=" + value + ", id=" + id + '}';
        }
    }

    static class Entry {
        final Event event;
        final Entry match; // null if current entry is a response, non-null if it's an invocation
        final int id; // internal id, distinct from Event.id
        Entry prev;
        Entry next;

        Entry(Event event, Entry match, int id) {
            this.event = event;
            this.match = match;
            this.id = id;
        }

        // removes this entry from the surrounding structures
        void lift() {
            prev.next = next;
            next.prev = prev;
            match.prev.next = match.next;
            if (match.next != null) {
                match.next.prev = match.prev;
            }
        }

        // reinserts this entry into the surrounding structures
        void unlift() {
            match.prev.next = match;
            if (match.next != null) {
                match.next.prev = match;
            }
            prev.next = this;
            next.prev = this;
        }
    }

    /**
     * A cache optimized for small bit-counts (less than 64) and small number of unique permutations of state objects.
     * 

* Each combination of states is kept once only, building on the * assumption that the number of permutations is small compared to the * number of bits permutations. For those histories that are difficult to check * we will have many bits combinations that use the same state permutations. *

* The smallMap optimization allows us to avoid object overheads for bit-sets up to 64 bit large. *

* Comparing set of (bits, state) to smallMap: * (bits, state) : 24 (tuple) + 24 (FixedBitSet) + 24 (bits) + 5 (hash buckets) + 24 (hashmap node). * smallMap bits to {state} : 10 (bits) + 5 (hash buckets) + avg-size of unique permutations. *

* The avg-size of the unique permutations part is very small compared to the * sometimes large number of bits combinations (which are the cases where * we run into trouble). *

* set of (bits, state) totals 101 bytes compared to smallMap bits to { state } * which totals 15 bytes, ie. a 6x improvement in memory usage. */ private static class Cache { private final Map> largeMap = new HashMap<>(); private final Map> smallMap = new HashMap<>(); private final Map internalizeStateMap = new HashMap<>(); private final Map, Set> statePermutations = new HashMap<>(); /** * Add state, bits combination * @return true if added, false if already registered. */ public boolean add(Object state, FixedBitSet bitSet) { return addInternal(internalizeStateMap.computeIfAbsent(state, k -> state), bitSet); } private boolean addInternal(Object state, FixedBitSet bitSet) { long[] bits = bitSet.getBits(); if (bits.length == 1) return addSmall(state, bits[0]); else return addLarge(state, bitSet); } private boolean addSmall(Object state, long bits) { Set states = smallMap.get(bits); if (states == null) { states = Set.of(state); } else { Set oldStates = states; if (oldStates.contains(state)) return false; states = new HashSet<>(oldStates.size() + 1); states.addAll(oldStates); states.add(state); } // Get a unique set object per state permutation. We assume that the number of permutations of states are small. // We thus avoid the overhead of the set data structure. states = statePermutations.computeIfAbsent(states, k -> k); smallMap.put(bits, states); return true; } private boolean addLarge(Object state, FixedBitSet bitSet) { return largeMap.computeIfAbsent(state, k -> new HashSet<>()).add(bitSet); } } }