Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions owlplug-client/src/main/java/com/owlplug/core/utils/Async.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/* OwlPlug
* Copyright (C) 2021 Arthur <dropsnorz@gmail.com>
*
* This file is part of OwlPlug.
*
* OwlPlug is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3
* as published by the Free Software Foundation.
*
* OwlPlug is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with OwlPlug. If not, see <https://www.gnu.org/licenses/>.
*/

package com.owlplug.core.utils;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public final class Async {

private static final Logger log = LoggerFactory.getLogger(Async.class);

// One virtual thread per task — safe for blocking I/O and database calls.
private static final Executor VIRTUAL = Executors.newVirtualThreadPerTaskExecutor();

private Async() {
}

/**
* Run a task on a virtual thread. Exceptions are logged as a side-effect;
* the returned future still completes exceptionally so callers can chain
* their own {@code .exceptionally()} if needed.
*/
public static CompletableFuture<Void> run(Runnable task) {
CompletableFuture<Void> cf = CompletableFuture.runAsync(task, VIRTUAL);
cf.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Unhandled async exception", ex);
}
});
return cf;
}

/**
* Supply a value on a virtual thread. Exceptions are logged as a side-effect;
* the returned future still completes exceptionally so callers can chain
* their own {@code .exceptionally()} if needed.
*/
public static <T> CompletableFuture<T> supply(Supplier<T> task) {
CompletableFuture<T> cf = CompletableFuture.supplyAsync(task, VIRTUAL);
cf.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Unhandled async exception", ex);
}
});
return cf;
}

/**
* Raw virtual-thread {@code runAsync} with no default handler — use when the
* caller owns exception handling entirely via {@code .exceptionally()}.
*/
public static CompletableFuture<Void> runAsync(Runnable task) {
return CompletableFuture.runAsync(task, VIRTUAL);
}

/**
* Raw virtual-thread {@code supplyAsync} with no default handler — use when
* the caller owns exception handling entirely via {@code .exceptionally()}.
*/
public static <T> CompletableFuture<T> supplyAsync(Supplier<T> task) {
return CompletableFuture.supplyAsync(task, VIRTUAL);
}

/**
* Ensures that only the result of the <em>latest</em> async call is ever
* delivered to the caller, discarding results from superseded invocations.
*
* <h3>Problem</h3>
* When the same async operation is triggered multiple times in quick
* succession (e.g. a UI selection change firing {@code refresh()}), results
* can arrive out of order: an older, slower query may resolve after a newer
* one, overwriting fresh data with stale data.
*
* <h3>Solution</h3>
* Each call to {@link #supply} or {@link #run} atomically claims a new
* <em>generation stamp</em>. When the result arrives, it is only forwarded
* if the stamp still matches the current generation — i.e. no newer call has
* been made in the meantime. Stale futures are silently left incomplete, so
* any chained {@code .thenAccept()} simply never fires. No extra logic is
* required at the call site.
*
* <h3>Usage</h3>
* Declare one {@code Sequence} field per independent refresh slot on the
* controller, then replace {@code Async.supply(...)} with
* {@code mySequence.supply(...)}:
* <pre>{@code
* private final Async.Sequence refreshSeq = new Async.Sequence();
*
* void refresh() {
* refreshSeq.supply(() -> repository.findAll())
* .thenAccept(data -> FX.run(() -> listView.setItems(data)));
* }
* }</pre>
*
* <h3>Notes</h3>
* <ul>
* <li>Exceptions are always logged, even for stale results, because a DB
* error is worth knowing about regardless of whether it was superseded.</li>
* <li>Stale futures are never completed, so they carry no memory overhead
* beyond normal GC eligibility once the chain is unreachable.</li>
* <li>Use one {@code Sequence} per independent data slot. A controller with
* two unrelated async loads should use two separate instances.</li>
* </ul>
*/
public static final class Sequence {

private final AtomicLong generation = new AtomicLong();

/**
* Submits {@code task} on a virtual thread and returns a guarded future.
* The future completes normally only if no newer call to this method has
* been made by the time the task finishes; otherwise it is left incomplete
* and downstream stages are never executed.
*
* @param task the blocking supplier to run off the FX thread
* @param <T> the result type
* @return a future that delivers the result only when it is still current
*/
public <T> CompletableFuture<T> supply(Supplier<T> task) {
// Claim this invocation's stamp before launching the task so that any
// call arriving concurrently gets a strictly higher generation number.
long stamp = generation.incrementAndGet();
CompletableFuture<T> inner = CompletableFuture.supplyAsync(task, VIRTUAL);
CompletableFuture<T> guarded = new CompletableFuture<>();
inner.whenComplete((result, ex) -> {
// Always log errors — a DB failure is worth knowing about even if a
// newer request has already superseded this one.
if (ex != null) {
log.error("Unhandled async exception", ex);
}
// Drop the result if a newer invocation has already claimed the slot.
// The guarded future is intentionally left incomplete; any chained
// .thenAccept() / .thenApply() will simply never fire.
if (generation.get() != stamp) {
return;
}
if (ex != null) {
guarded.completeExceptionally(ex);
} else {
guarded.complete(result);
}
});
return guarded;
}

/**
* Submits {@code task} on a virtual thread and returns a guarded future.
* Behaves identically to {@link #supply} but for fire-and-forget tasks
* that produce no value.
*
* @param task the blocking runnable to run off the FX thread
* @return a future that completes only when this invocation is still current
*/
public CompletableFuture<Void> run(Runnable task) {
long stamp = generation.incrementAndGet();
CompletableFuture<Void> inner = CompletableFuture.runAsync(task, VIRTUAL);
CompletableFuture<Void> guarded = new CompletableFuture<>();
inner.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Unhandled async exception", ex);
}
if (generation.get() != stamp) {
return;
}
if (ex != null) {
guarded.completeExceptionally(ex);
} else {
guarded.complete(null);
}
});
return guarded;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import com.owlplug.controls.DialogLayout;
import com.owlplug.controls.DoughnutChart;
import com.owlplug.core.controllers.BaseController;
import com.owlplug.core.utils.Async;
import com.owlplug.core.utils.FX;
import com.owlplug.core.utils.FileUtils;
import com.owlplug.core.utils.PlatformUtils;
import com.owlplug.core.utils.StringUtils;
Expand Down Expand Up @@ -64,6 +66,11 @@ public class DirectoryInfoController extends BaseController {
@Autowired
private FileStatRepository fileStatRepository;

// One sequence per refresh slot: guarantees that only the result of the
// latest refresh() call is applied to the UI, even if an older DB query
// resolves later.
private final Async.Sequence refreshSequence = new Async.Sequence();

@FXML
private Label directoryNameLabel;
@FXML
Expand All @@ -90,6 +97,7 @@ public class DirectoryInfoController extends BaseController {
private TableColumn<FileStat, String> fileSizeColumn;
private PieChart pieChart;


private final ObjectProperty<PluginDirectory> pluginDirectoryProperty = new SimpleObjectProperty<>();

/**
Expand Down Expand Up @@ -168,38 +176,38 @@ protected void layoutChartChildren(double top, double left, double contentWidth,

/**
* Refresh directory info.
* Most database accesses are performed in this method and expected to be run on
* UI thread to work around a bug with charts display with concurrent updates.
*/
public void refresh() {
PluginDirectory pluginDirectory = pluginDirectoryProperty.get();

directoryPathTextField.setText(pluginDirectory.getPath());
directoryNameLabel.setText(pluginDirectory.getName());
pluginDirectoryListView.getItems().setAll(pluginDirectory.getPluginList());
directoryPluginsTab.setText("Plugins (" + pluginDirectory.getPluginList().size() + ")");
directoryMetricsTab.setText("0 KB");

File file = new File(pluginDirectory.getPath());
deleteDirectoryButton.setDisable(!file.canWrite());
deleteDirectoryButton.setDisable(!new File(pluginDirectory.getPath()).canWrite());

String path = pluginDirectory.getPath();
if (path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}

directoryPluginsTab.setText("Plugins (" + pluginDirectory.getPluginList().size() + ")");

Optional<FileStat> directoryStat = fileStatRepository.findByPath(path);
directoryStat.ifPresent(fileStat -> directoryMetricsTab.setText(
FileUtils.humanReadableByteCount(fileStat.getLength(), true)));

List<FileStat> fileStats = fileStatRepository.findByParentPathOrderByLengthDesc(path);
directoryFilesTab.setText("Files (" + fileStats.size() + ")");
directoryFilesTableView.setItems(FXCollections.observableArrayList(fileStats));
pieChart.setData(createStatChartBuckets(fileStats));
pieChart.layout();

final String resolvedPath = path;

refreshSequence.supply(() -> new FileStatResults(
fileStatRepository.findByPath(resolvedPath),
fileStatRepository.findByParentPathOrderByLengthDesc(resolvedPath)
)).thenAccept(results -> FX.run(() -> {
results.directoryStat().ifPresent(fileStat ->
directoryMetricsTab.setText(FileUtils.humanReadableByteCount(fileStat.getLength(), true)));
directoryFilesTab.setText("Files (" + results.fileStats().size() + ")");
directoryFilesTableView.setItems(FXCollections.observableArrayList(results.fileStats()));
pieChart.setData(createStatChartBuckets(results.fileStats()));

}));
}

private record FileStatResults(Optional<FileStat> directoryStat, List<FileStat> fileStats) {}

private ObservableList<PieChart.Data> createStatChartBuckets(List<FileStat> fileStats) {
ObservableList<PieChart.Data> chartData = FXCollections.observableArrayList();
int i = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import com.owlplug.core.components.ApplicationDefaults;
import com.owlplug.core.components.ImageCache;
import com.owlplug.core.controllers.BaseController;
import com.owlplug.core.utils.Async;
import com.owlplug.core.utils.FX;
import com.owlplug.core.utils.PlatformUtils;
import com.owlplug.plugin.components.PluginTaskFactory;
Expand All @@ -31,13 +32,14 @@
import com.owlplug.plugin.events.PluginUpdateEvent;
import com.owlplug.plugin.model.Plugin;
import com.owlplug.plugin.model.PluginComponent;
import com.owlplug.plugin.model.PluginState;
import com.owlplug.plugin.services.PluginService;
import com.owlplug.plugin.ui.PluginComponentCellFactory;
import com.owlplug.plugin.ui.PluginStateView;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
Expand Down Expand Up @@ -113,6 +115,10 @@ public class PluginInfoController extends BaseController {
private final ObjectProperty<Plugin> pluginProperty = new SimpleObjectProperty<Plugin>();
private final ArrayList<String> knownPluginImages = new ArrayList<>();

// One sequence per refresh slot: guarantees only the latest selection's data
// reaches the UI, even if a previous DB query resolves out of order.
private final Async.Sequence refreshSequence = new Async.Sequence();

/**
* FXML initialize method.
*/
Expand Down Expand Up @@ -147,7 +153,7 @@ public void initialize() {

enableButton.setOnAction(e -> {
Plugin plugin = pluginProperty.get();
CompletableFuture.runAsync(() -> pluginService.enablePlugin(plugin));
Async.run(() -> pluginService.enablePlugin(plugin));
});

pluginComponentListView.setCellFactory(new PluginComponentCellFactory(this.getApplicationDefaults()));
Expand All @@ -156,7 +162,7 @@ public void initialize() {
Plugin plugin = pluginProperty.get();
if (plugin != null && plugin.getFootprint() != null) {
plugin.getFootprint().setNativeDiscoveryEnabled(newValue);
CompletableFuture.runAsync(() -> pluginService.save(plugin.getFootprint()));
Async.run(() -> pluginService.save(plugin.getFootprint()));
}
});

Expand All @@ -168,6 +174,9 @@ public void refresh() {
if (plugin == null) {
return;
}

// All reads below come from already-loaded in-memory fields — safe to run
// on the FX thread without risk of blocking.
pluginFormatIcon.setImage(this.getApplicationDefaults().getPluginFormatIcon(plugin.getFormat()));
pluginFormatLabel.setText(plugin.getFormat().getText() + " Plugin");
pluginTitleLabel.setText(plugin.getName());
Expand All @@ -176,11 +185,8 @@ public void refresh() {
pluginManufacturerLabel.setText(Optional.ofNullable(plugin.getManufacturerName()).orElse("Unknown"));
pluginIdentifierLabel.setText(Optional.ofNullable(plugin.getUid()).orElse("Unknown"));
pluginCategoryLabel.setText(Optional.ofNullable(plugin.getCategory()).orElse("Unknown"));
pluginStateView.setPluginState(pluginService.getPluginState(plugin));
pluginPathLabel.setText(plugin.getPath());

File file = new File(plugin.getPath());
this.uninstallButton.setDisable(!file.canWrite());
uninstallButton.setDisable(!new File(plugin.getPath()).canWrite());

if (plugin.isDisabled()) {
enableButton.setManaged(true);
Expand All @@ -198,12 +204,23 @@ public void refresh() {
nativeDiscoveryToggleButton.setSelected(plugin.getFootprint().isNativeDiscoveryEnabled());
}

ObservableList<PluginComponent> components = FXCollections.observableList(new ArrayList(plugin.getComponents()));
pluginComponentListView.setItems(components);

setPluginImage();

// getPluginState may hit the database, and getComponents may trigger a
// lazy JPA load — offload both to a virtual thread. The sequence ensures
// that if the user selects another plugin before this resolves, the
// stale result is silently dropped.
refreshSequence.supply(() -> new PluginRefreshData(
pluginService.getPluginState(plugin),
new ArrayList<>(plugin.getComponents())
)).thenAccept(data -> FX.run(() -> {
pluginStateView.setPluginState(data.state());
pluginComponentListView.setItems(FXCollections.observableList(data.components()));
}));
}

private record PluginRefreshData(PluginState state, List<PluginComponent> components) {}

private void setPluginImage() {
Plugin plugin = pluginProperty.get();
String url = plugin.getScreenshotUrl();
Expand Down
Loading