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

org.elasticsearch.common.util.CancellableSingleObjectCache Maven / Gradle / Ivy

There is a newer version: 8.14.0
Show newest version
/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License
 * 2.0 and the Server Side Public License, v 1; you may not use this file except
 * in compliance with, at your election, the Elastic License 2.0 or the Server
 * Side Public License, v 1.
 */

package org.elasticsearch.common.util;

import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.support.ContextPreservingActionListener;
import org.elasticsearch.action.support.ListenableActionFuture;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.core.AbstractRefCounted;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.tasks.TaskCancelledException;

import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;

/**
 * A cache of a single object whose refresh process can be cancelled. The cached value is computed lazily on the first retrieval, and
 * associated with a key which is used to determine its freshness for subsequent retrievals.
 * 

* This is useful for things like computing stats over cluster metadata: the first time stats are requested they are computed, but * subsequent calls re-use the computed value as long as they pertain to the same metadata version. If stats are requested for a different * metadata version then the cached value is dropped and a new one is computed. *

* Retrievals happen via the async {@link #get} method. If a retrieval is cancelled (e.g. the channel on which to return the stats is * closed) then the computation carries on running in case another retrieval for the same key arrives in future. However if all of the * retrievals for a key are cancelled and a retrieval occurs for a fresher key then the computation itself is cancelled. *

* Cancellation is based on polling: the {@link #refresh} method checks whether it should abort whenever it is convenient to do so, which in * turn checks all the pending retrievals to see whether they have been cancelled. * * @param The type of the input to the computation of the cached value. * @param The key type. The cached value is associated with a key, and subsequent {@link #get} calls compare keys of the given input * value to determine whether the cached value is fresh or not. See {@link #isFresh}. * @param The type of the cached value. */ public abstract class CancellableSingleObjectCache { private final ThreadContext threadContext; private final AtomicReference currentCachedItemRef = new AtomicReference<>(); protected CancellableSingleObjectCache(ThreadContext threadContext) { this.threadContext = threadContext; } /** * Compute a new value for the cache. *

* If an exception is thrown, or passed to the {@code listener}, then it is passed on to all waiting listeners but it is not cached so * that subsequent retrievals will trigger subsequent calls to this method. *

* Implementations of this method should poll for cancellation by running {@code ensureNotCancelled} whenever appropriate. The * computation is cancelled if all of the corresponding retrievals have been cancelled and a retrieval has since happened for a * fresher key. * * @param input The input to this computation, which will be converted to a key and used to determine whether it is * suitably fresh for future requests too. * @param ensureNotCancelled A {@link Runnable} which throws a {@link TaskCancelledException} if the result of the computation is no * longer needed. On cancellation, notifying the {@code listener} is optional. * @param supersedeIfStale Checks whether the {@code input} to this refresh has been superseded by a fresher input. If the current * input has been superseded then this supplier subscribes the {@code listener} (and corresponding * cancellation checks) to the computation from the new input and returns {@code true}, indicating that no * further action is needed by this invocation of {@code refresh()}. If the current input is still the * freshest then it takes no action and returns {@code false} to indicate that this invocation of {@code * refresh()} must proceed. Implementations of {@code refresh()} that work asynchronously, for instance by * running the computation on a different thread, should use this to check for freshness when they resume. * @param listener A {@link ActionListener} which should be notified when the computation completes. If the computation fails * by calling {@link ActionListener#onFailure} then the result is returned to the pending listeners but is not * cached. */ protected abstract void refresh( Input input, Runnable ensureNotCancelled, BooleanSupplier supersedeIfStale, ActionListener listener ); /** * Compute the key for the given input value. */ protected abstract Key getKey(Input input); /** * Compute whether the {@code currentKey} is fresh enough for a retrieval associated with {@code newKey}. * * @param currentKey The key of the current (cached or pending) value. * @param newKey The key associated with a new retrival. * @return {@code true} if a value computed for {@code currentKey} is fresh enough to satisfy a retrieval for {@code newKey}. */ protected boolean isFresh(Key currentKey, Key newKey) { return currentKey.equals(newKey); } /** * Start a retrieval for the value associated with the given {@code input}, and pass it to the given {@code listener}. *

* If a fresh-enough result is available when this method is called then the {@code listener} is notified immediately, on this thread. * If a fresh-enough result is already being computed then the {@code listener} is captured and will be notified when the result becomes * available, on the thread on which the refresh completes. If no fresh-enough result is either pending or available then this method * starts to compute one by calling {@link #refresh} on this thread. * * @param input The input to compute the desired value, converted to a {@link Key} to determine if the value that's currently * cached or pending is fresh enough. * @param isCancelled Returns {@code true} if the listener no longer requires the value being computed. * @param listener The listener to notify when the desired value becomes available. */ public final void get(Input input, BooleanSupplier isCancelled, ActionListener listener) { final Key key = getKey(input); CachedItem newCachedItem = null; do { if (isCancelled.getAsBoolean()) { listener.onFailure(new TaskCancelledException("task cancelled")); return; } final CachedItem currentCachedItem = currentCachedItemRef.get(); if (currentCachedItem != null && isFresh(currentCachedItem.getKey(), key)) { final boolean listenerAdded = currentCachedItem.addListener(listener, isCancelled); if (listenerAdded) { return; } assert currentCachedItem.refCount() == 0 : currentCachedItem.refCount(); assert currentCachedItemRef.get() != currentCachedItem; // Our item was only just released, possibly cancelled, by another get() with a fresher key. We don't simply retry // since that would evict the new item. Instead let's see if it was cancelled or whether it completed properly. if (currentCachedItem.getFuture().isDone()) { try { listener.onResponse(currentCachedItem.getFuture().actionGet(0L)); return; } catch (TaskCancelledException e) { // previous task was cancelled before completion, therefore we must perform our own one-shot refresh } catch (Exception e) { // either the refresh completed exceptionally or the listener threw an exception; call onFailure() either way listener.onFailure(e); return; } } // else it's just about to be cancelled, so we can just retry knowing that it will be removed very soon continue; } if (newCachedItem == null) { newCachedItem = new CachedItem(key); } if (currentCachedItemRef.compareAndSet(currentCachedItem, newCachedItem)) { if (currentCachedItem != null) { currentCachedItem.decRef(); } final boolean listenerAdded = newCachedItem.addListener(listener, isCancelled); assert listenerAdded; newCachedItem.decRef(); // release our ref before calling refresh so that we're not blocking a cancellation newCachedItem.startRefresh(input); return; } // else the CAS failed because we lost a race to a concurrent retrieval; try again from the top since we expect the race winner // to be fresh enough for us and therefore we can just wait for its result. } while (true); } /** * An item in the cache, representing a single invocation of {@link #refresh}. *

* This item is ref-counted so that it can be cancelled if it becomes irrelevant. References are held by: *

    *
  • Every listener that is waiting for the result, released on cancellation. There's no need to release on completion because * there's nothing to cancel once the refresh has completed.
  • *
  • The cache itself, released once this item is no longer the current one in the cache, either because it failed or because a * fresher computation was started.
  • *
  • The process that adds the first listener, released once the first listener is added.
  • *
*/ private final class CachedItem extends AbstractRefCounted { private final Key key; private final ListenableActionFuture future = new ListenableActionFuture<>(); private final CancellationChecks cancellationChecks = new CancellationChecks(); CachedItem(Key key) { this.key = key; incRef(); // start with a refcount of 2 so we're not closed while adding the first listener this.future.addListener(new ActionListener() { @Override public void onResponse(Value value) { cancellationChecks.clear(); } @Override public void onFailure(Exception e) { cancellationChecks.clear(); // Do not cache this failure if (currentCachedItemRef.compareAndSet(CachedItem.this, null)) { // Release reference held by the cache, so that concurrent calls to addListener() fail and retry. Not totally // necessary, we could also fail those listeners as if they'd been added slightly sooner, but it makes the ref // counting easier to document. decRef(); } } }); } Key getKey() { return key; } ListenableActionFuture getFuture() { return future; } boolean addListener(ActionListener listener, BooleanSupplier isCancelled) { if (tryIncRef()) { if (future.isDone()) { // No need to bother with ref counting & cancellation any more, just complete the listener. // We know it wasn't cancelled because there are still references. ActionListener.completeWith(listener, () -> future.actionGet(0L)); } else { // Refresh is still pending; it's not cancelled because there are still references. future.addListener(ContextPreservingActionListener.wrapPreservingContext(listener, threadContext)); final AtomicBoolean released = new AtomicBoolean(); cancellationChecks.add(() -> { if (released.get() == false && isCancelled.getAsBoolean() && released.compareAndSet(false, true)) { decRef(); } }); } return true; } else { return false; } } private void ensureNotCancelled() { cancellationChecks.runAll(); if (hasReferences() == false) { throw new TaskCancelledException("task cancelled"); } } @Override protected void closeInternal() { // Complete the future (and hence all its listeners) with an exception if it hasn't already been completed. future.onFailure(new TaskCancelledException("task cancelled")); } private boolean supersedeIfStale() { final CachedItem currentCachedItem = currentCachedItemRef.get(); if (currentCachedItem == this) { // this item is still the freshest return false; } if (currentCachedItem == null) { // this item was superseded but the newer item was cancelled so we must proceed with a refresh for this item return false; } cancellationChecks.runAll(); if (tryIncRef()) { try { return currentCachedItem.addListener(future, () -> { cancellationChecks.runAll(); return hasReferences() == false; }); } finally { decRef(); } } else { // this item was cancelled, not superseded, so the refresh must complete (typically with a cancellation exception) return false; } } void startRefresh(Input input) { try { refresh(input, this::ensureNotCancelled, this::supersedeIfStale, future); } catch (Exception e) { future.onFailure(e); } } } private static final class CancellationChecks { @Nullable // if cleared private ArrayList checks = new ArrayList<>(); synchronized void clear() { checks = null; } synchronized void add(Runnable check) { if (checks != null) { checks.add(check); } } void runAll() { // It's ok not to run all the checks so there's no need for a completely synchronized iteration. final int count; synchronized (this) { if (checks == null) { return; } count = checks.size(); } for (int i = 0; i < count; i++) { final Runnable cancellationCheck; synchronized (this) { if (checks == null) { return; } cancellationCheck = checks.get(i); } cancellationCheck.run(); } } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy