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

io.activej.fs.cluster.ClusterActiveFs Maven / Gradle / Ivy

Go to download

Provides tools for building efficient, scalable local, remote or clustered file servers. It utilizes ActiveJ CSP for fast and reliable file transfer.

There is a newer version: 6.0-beta2
Show newest version
/*
 * Copyright (C) 2020 ActiveJ LLC.
 *
 * 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.activej.fs.cluster;

import io.activej.async.function.AsyncSupplier;
import io.activej.async.process.AsyncCloseable;
import io.activej.async.service.EventloopService;
import io.activej.bytebuf.ByteBuf;
import io.activej.common.api.WithInitializer;
import io.activej.common.collection.Try;
import io.activej.common.exception.MalformedDataException;
import io.activej.common.ref.RefBoolean;
import io.activej.csp.ChannelConsumer;
import io.activej.csp.ChannelSupplier;
import io.activej.csp.dsl.ChannelConsumerTransformer;
import io.activej.eventloop.Eventloop;
import io.activej.eventloop.jmx.EventloopJmxBeanEx;
import io.activej.fs.ActiveFs;
import io.activej.fs.FileMetadata;
import io.activej.fs.exception.FsIOException;
import io.activej.jmx.api.attribute.JmxAttribute;
import io.activej.jmx.api.attribute.JmxOperation;
import io.activej.promise.Promise;
import io.activej.promise.Promises;
import io.activej.promise.jmx.PromiseStats;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Duration;
import java.util.*;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Stream;

import static io.activej.common.Checks.checkArgument;
import static io.activej.common.collection.CollectionUtils.transformIterator;
import static io.activej.csp.dsl.ChannelConsumerTransformer.identity;
import static io.activej.fs.util.RemoteFsUtils.ofFixedSize;
import static io.activej.promise.Promises.first;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;

/**
 * An implementation of {@link ActiveFs} which operates on other partitions as a cluster.
 * Contains some redundancy and fail-safety capabilities.
 * 

* This implementation inherits the most strict limitations of all the file systems in cluster, * as well as defines several limitations over those specified in {@link ActiveFs} interface: *

    *
  • Uploaded files should be immutable
  • *
  • Deletion of files is not guaranteed
  • *
  • Based on previous limitation, moving a file also does not guarantees that source file will be deleted
  • *
  • Paths should not contain existing filenames as part of the path
  • *
*/ public final class ClusterActiveFs implements ActiveFs, WithInitializer, EventloopService, EventloopJmxBeanEx { private static final Logger logger = LoggerFactory.getLogger(ClusterActiveFs.class); private final FsPartitions partitions; /** * Maximum allowed number of dead partitions, if there are more dead partitions than this number, * the cluster is considered malformed. */ private int deadPartitionsThreshold = 0; /** * Minimum number of uploads that have to succeed in order for upload to be considered successful. */ private int uploadTargetsMin = 1; /** * Initial number of uploads that are initiated. */ private int uploadTargetsMax = 1; // region JMX private final PromiseStats uploadStartPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats uploadFinishPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats appendStartPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats appendFinishPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats downloadStartPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats downloadFinishPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats listPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats infoPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats infoAllPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats copyPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats copyAllPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats movePromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats moveAllPromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats deletePromise = PromiseStats.create(Duration.ofMinutes(5)); private final PromiseStats deleteAllPromise = PromiseStats.create(Duration.ofMinutes(5)); // endregion // region creators private ClusterActiveFs(FsPartitions partitions) { this.partitions = partitions; } public static ClusterActiveFs create(FsPartitions partitions) { return new ClusterActiveFs(partitions); } /** * Sets the replication count that determines how many copies of the file should persist over the cluster. */ public ClusterActiveFs withReplicationCount(int replicationCount) { checkArgument(1 <= replicationCount && replicationCount <= partitions.getPartitions().size(), "Replication count cannot be less than one or greater than number of partitions"); this.deadPartitionsThreshold = replicationCount - 1; this.uploadTargetsMin = replicationCount; this.uploadTargetsMax = replicationCount; return this; } /** * Sets the replication count as well as number of upload targets that determines the number of server where file will be uploaded. */ @SuppressWarnings("UnusedReturnValue") public ClusterActiveFs withPersistenceOptions(int deadPartitionsThreshold, int uploadTargetsMin, int uploadTargetsMax) { checkArgument(0 <= deadPartitionsThreshold && deadPartitionsThreshold < partitions.getPartitions().size(), "Dead partitions threshold cannot be less than zero or greater than number of partitions"); checkArgument(0 <= uploadTargetsMin, "Minimum number of upload targets should not be less than zero"); checkArgument(0 < uploadTargetsMax && uploadTargetsMin <= uploadTargetsMax && uploadTargetsMax <= partitions.getPartitions().size(), "Maximum number of upload targets should be greater than zero, " + "should not be less than minimum number of upload targets and" + "should not exceed total number of partitions"); this.deadPartitionsThreshold = deadPartitionsThreshold; this.uploadTargetsMin = uploadTargetsMin; this.uploadTargetsMax = uploadTargetsMax; return this; } // endregion // region getters @NotNull @Override public Eventloop getEventloop() { return partitions.getEventloop(); } // endregion @Override public Promise> upload(@NotNull String name) { return doUpload(name, fs -> fs.upload(name), identity(), uploadStartPromise, uploadFinishPromise); } @Override public Promise> upload(@NotNull String name, long size) { return doUpload(name, fs -> fs.upload(name, size), ofFixedSize(size), uploadStartPromise, uploadFinishPromise); } @Override public Promise> append(@NotNull String name, long offset) { return doUpload(name, fs -> fs.append(name, offset), identity(), appendStartPromise, appendFinishPromise); } @Override public Promise> download(@NotNull String name, long offset, long limit) { return broadcast( (id, fs) -> { logger.trace("downloading file {} from {}", name, id); return fs.download(name, offset, limit) .whenException(e -> logger.warn("Failed to connect to a server with key " + id + " to download file " + name, e)) .map(supplier -> supplier .withEndOfStream(eos -> eos .thenEx(partitions.wrapDeath(id)))); }, AsyncCloseable::close) .then(filterErrors(() -> ofFailure("Could not download file '" + name + "' from any server"))) .then(suppliers -> { ChannelByteCombiner combiner = ChannelByteCombiner.create(); for (ChannelSupplier supplier : suppliers) { combiner.addInput().set(supplier); } return Promise.of(combiner.getOutput().getSupplier()); }) .whenComplete(downloadStartPromise.recordStats()); } @Override public Promise copy(@NotNull String name, @NotNull String target) { return ActiveFs.super.copy(name, target) .whenComplete(copyPromise.recordStats()); } @Override public Promise copyAll(Map sourceToTarget) { return ActiveFs.super.copyAll(sourceToTarget) .whenComplete(copyAllPromise.recordStats()); } @Override public Promise move(@NotNull String name, @NotNull String target) { return ActiveFs.super.move(name, target) .whenComplete(movePromise.recordStats()); } @Override public Promise moveAll(Map sourceToTarget) { return ActiveFs.super.moveAll(sourceToTarget) .whenComplete(moveAllPromise.recordStats()); } @Override public Promise delete(@NotNull String name) { return broadcast(fs -> fs.delete(name)) .whenComplete(deletePromise.recordStats()) .toVoid(); } @Override public Promise deleteAll(Set toDelete) { return broadcast(fs -> fs.deleteAll(toDelete)) .whenComplete(deleteAllPromise.recordStats()) .toVoid(); } @Override public Promise> list(@NotNull String glob) { return broadcast(fs -> fs.list(glob)) .then(filterErrors()) .map(maps -> FileMetadata.flatten(maps.stream())) .whenComplete(listPromise.recordStats()); } @Override public Promise<@Nullable FileMetadata> info(@NotNull String name) { return broadcast(fs -> fs.info(name)) .then(filterErrors()) .map(meta -> meta.stream().max(FileMetadata.COMPARATOR).orElse(null)) .whenComplete(infoPromise.recordStats()); } @Override public Promise> infoAll(@NotNull Set names) { if (names.isEmpty()) return Promise.of(emptyMap()); return broadcast(fs -> fs.infoAll(names)) .then(filterErrors()) .map(maps -> FileMetadata.flatten(maps.stream())) .whenComplete(infoAllPromise.recordStats()); } @Override public Promise ping() { return partitions.checkAllPartitions() .then(this::checkNotDead); } @NotNull @Override public Promise start() { return ping(); } @NotNull @Override public Promise stop() { return Promise.complete(); } @Override public String toString() { return "ClusterActiveFs{partitions=" + partitions + '}'; } private static Promise ofFailure(String message) { return Promise.ofException(new FsIOException(message)); } private Promise checkStillNotDead(T value) { Map deadPartitions = partitions.getDeadPartitions(); if (deadPartitions.size() > deadPartitionsThreshold) { return ofFailure("There are more dead partitions than allowed(" + deadPartitions.size() + " dead, threshold is " + deadPartitionsThreshold + "), aborting"); } return Promise.of(value); } private Promise checkNotDead() { return checkStillNotDead(null); } private Promise> doUpload( String name, Function>> action, ChannelConsumerTransformer> transformer, PromiseStats startStats, PromiseStats finishStats) { return checkNotDead() .then(() -> collect(name, action)) .then(containers -> { ChannelByteSplitter splitter = ChannelByteSplitter.create(uploadTargetsMin); for (Container> container : containers) { splitter.addOutput().set(container.value); } if (logger.isTraceEnabled()) { logger.trace("uploading file {} to {}, {}", name, containers.stream() .map(container -> container.id.toString()) .collect(joining(", ", "[", "]")), this); } return Promise.of(splitter.getInput().getConsumer() .transformWith(transformer)) .whenComplete(finishStats.recordStats()); }) .whenComplete(startStats.recordStats()); } private Promise>>> collect( String name, Function>> action ) { Iterator idIterator = partitions.select(name).iterator(); Set> consumers = new HashSet<>(); RefBoolean failed = new RefBoolean(false); return Promises.toList( Stream.generate(() -> first( transformIterator(idIterator, id -> call(id, action) .whenResult(consumer -> { if (failed.get()) { consumer.close(); } else { consumers.add(consumer); } }) .map(consumer -> new Container<>(id, consumer.withAcknowledgement(ack -> ack.thenEx(partitions.wrapDeath(id)))))))) .limit(uploadTargetsMax)) .thenEx((containers, e) -> { if (e != null) { consumers.forEach(AsyncCloseable::close); failed.set(true); return ofFailure("Didn't connect to enough partitions to upload '" + name + '\''); } return Promise.of(containers); }); } private Promise call(Object id, Function> action) { return call(id, ($, fs) -> action.apply(fs)); } private Promise call(Object id, BiFunction> action) { ActiveFs fs = partitions.get(id); if (fs == null) { // marked as dead already by somebody return Promise.ofException(new FsIOException("Partition '" + id + "' is not alive")); } return action.apply(id, fs) .thenEx(partitions.wrapDeath(id)); } private Promise>> broadcast(BiFunction> action, Consumer cleanup) { return checkNotDead() .then(() -> Promise.ofCallback(cb -> Promises.toList(partitions.getAlivePartitions().entrySet().stream() .map(entry -> action.apply(entry.getKey(), entry.getValue()) .thenEx(partitions.wrapDeath(entry.getKey())) .whenResult(result -> { if (cb.isComplete()) { cleanup.accept(result); } }) .toTry() .then(aTry -> checkStillNotDead(aTry) .whenException(e -> aTry.ifSuccess(cleanup))))) .whenComplete(cb))); } private Function>, Promise>> filterErrors() { return filterErrors(() -> Promise.of(emptyList())); } private Function>, Promise>> filterErrors(AsyncSupplier> fallback) { return tries -> { List successes = tries.stream().filter(Try::isSuccess).map(Try::get).collect(toList()); if (!successes.isEmpty()) { return Promise.of(successes); } List exceptions = tries.stream().filter(Try::isException).map(Try::getException).collect(toList()); if (!exceptions.isEmpty()) { Throwable exception = exceptions.get(0); if (exceptions.stream().skip(1).allMatch(e -> e == exception)) { return Promise.ofException(exception); } } return fallback.get(); }; } private Promise>> broadcast(Function> action) { return broadcast(($, fs) -> action.apply(fs), $ -> {}); } private static class Container { final Object id; final T value; Container(Object id, T value) { this.id = id; this.value = value; } } // region JMX @JmxAttribute public int getDeadPartitionsThreshold() { return deadPartitionsThreshold; } @JmxAttribute public int getUploadTargetsMin() { return uploadTargetsMin; } @JmxAttribute public int getUploadTargetsMax() { return uploadTargetsMax; } @JmxOperation public void setReplicationCount(int replicationCount) { withReplicationCount(replicationCount); } @JmxAttribute public void setDeadPartitionsThreshold(int deadPartitionsThreshold) { withPersistenceOptions(deadPartitionsThreshold, uploadTargetsMin, uploadTargetsMax); } @JmxAttribute public void setUploadTargetsMin(int uploadTargetsMin) { withPersistenceOptions(deadPartitionsThreshold, uploadTargetsMin, uploadTargetsMax); } @JmxAttribute public void setUploadTargetsMax(int uploadTargetsMax) { withPersistenceOptions(deadPartitionsThreshold, uploadTargetsMin, uploadTargetsMax); } @JmxAttribute public int getAlivePartitionCount() { return partitions.getAlivePartitions().size(); } @JmxAttribute public int getDeadPartitionCount() { return partitions.getDeadPartitions().size(); } @JmxAttribute public String[] getAlivePartitions() { return partitions.getAlivePartitions().keySet().stream() .map(Object::toString) .toArray(String[]::new); } @JmxAttribute public String[] getDeadPartitions() { return partitions.getDeadPartitions().keySet().stream() .map(Object::toString) .toArray(String[]::new); } @JmxAttribute public PromiseStats getUploadStartPromise() { return uploadStartPromise; } @JmxAttribute public PromiseStats getUploadFinishPromise() { return uploadFinishPromise; } @JmxAttribute public PromiseStats getAppendStartPromise() { return appendStartPromise; } @JmxAttribute public PromiseStats getAppendFinishPromise() { return appendFinishPromise; } @JmxAttribute public PromiseStats getDownloadStartPromise() { return downloadStartPromise; } @JmxAttribute public PromiseStats getDownloadFinishPromise() { return downloadFinishPromise; } @JmxAttribute public PromiseStats getListPromise() { return listPromise; } @JmxAttribute public PromiseStats getInfoPromise() { return infoPromise; } @JmxAttribute public PromiseStats getInfoAllPromise() { return infoAllPromise; } @JmxAttribute public PromiseStats getDeletePromise() { return deletePromise; } @JmxAttribute public PromiseStats getDeleteAllPromise() { return deleteAllPromise; } @JmxAttribute public PromiseStats getCopyPromise() { return copyPromise; } @JmxAttribute public PromiseStats getCopyAllPromise() { return copyAllPromise; } @JmxAttribute public PromiseStats getMovePromise() { return movePromise; } @JmxAttribute public PromiseStats getMoveAllPromise() { return moveAllPromise; } @JmxAttribute(name = "") public FsPartitions getPartitions() { return partitions; } @JmxOperation public void setPartitions(String partitionString) throws MalformedDataException { this.partitions.setPartitions(Arrays.stream(partitionString.split(";")) .map(String::trim) .filter(s -> !s.isEmpty()) .collect(toList())); } // endregion }