refactor(task): consolidate 6 executor pools into 3 by resource profile

TaskService/ExecutorConfig had one ThreadPoolTaskExecutor per TaskType
(DATABASE, THUMBNAIL, METADATA, IMAGE_ANALYSIS, BACKGROUND_SYNC, HASH),
each with its own hand-tuned size, but the actual work only falls into
three resource profiles: a single-writer DB pool (unchanged), a CPU-bound
pool for native-codec-backed decode/hash work (THUMBNAIL+IMAGE_ANALYSIS,
still bounded platform threads to avoid oversubscribing the CPU and
native-call pinning), and an I/O-bound pool (METADATA+BACKGROUND_SYNC+HASH)
now backed by Executors.newVirtualThreadPerTaskExecutor() instead of a
guessed thread count, since none of that work touches native code.

TaskType keeps its 6 values as semantic labels; TaskService now maps
several of them onto the same underlying executor instead of injecting
six. PholioProperties.Pools drops thumbnail/metadata/image-analysis/
background-sync/hash in favour of a single cpu size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:00:45 -04:00
co-authored by Claude Sonnet 5
parent 09c58aba1b
commit 5004c17fc7
20 changed files with 76 additions and 93 deletions
@@ -5,6 +5,8 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
/**
@@ -13,26 +15,33 @@ import java.util.concurrent.ThreadPoolExecutor;
* <p>Every pool is a Spring bean so that closing the context shuts them all down — which is what the
* window's close handler relies on to release H2 connections, caches and in-flight decodes.
*
* <p>Two deliberate choices worth keeping:
* <p>Three deliberate choices worth keeping:
* <ul>
* <li>{@code databaseExecutor} is <strong>single-threaded</strong>. H2 in file mode serialises
* writes anyway; funnelling them through one thread removes lock contention and makes batch
* import ordering deterministic.
* <li>Rejection policy is {@link ThreadPoolExecutor.AbortPolicy}, never {@code CallerRunsPolicy}.
* A caller-runs policy would execute rejected work on whichever thread submitted it — and the
* submitter is frequently the JavaFX application thread, which would freeze the UI. Callers
* handle {@link java.util.concurrent.RejectedExecutionException} instead.
* <li>{@code cpuExecutor} stays a bounded, platform-thread pool ({@code cores - 1}) rather than
* virtual threads: everything it runs (thumbnail decode/resize/encode, the perceptual hash) is
* genuinely CPU-bound and goes through the JDK's native image codec — an unbounded
* virtual-thread-per-task executor would oversubscribe the CPU instead of queueing, and a
* blocked native call pins whatever thread runs it.
* <li>{@code ioExecutor} is the opposite: EXIF header reads, filesystem scans and file hashing
* (often over a NAS share) block on I/O, not CPU, and touch nothing native — exactly where
* {@link Executors#newVirtualThreadPerTaskExecutor()} pays off, since concurrency then scales
* with whatever the I/O can actually sustain instead of a hand-picked thread count.
* <li>Rejection policy for the two platform-thread pools is {@link ThreadPoolExecutor.AbortPolicy},
* never {@code CallerRunsPolicy}. A caller-runs policy would execute rejected work on whichever
* thread submitted it — and the submitter is frequently the JavaFX application thread, which
* would freeze the UI. Callers handle {@link java.util.concurrent.RejectedExecutionException}
* instead. {@code ioExecutor} has no bounded queue to reject from in the first place.
* </ul>
*/
@Configuration(proxyBeanMethods = false)
public class ExecutorConfig {
public static final String DATABASE_EXECUTOR = "databaseExecutor";
public static final String THUMBNAIL_EXECUTOR = "thumbnailExecutor";
public static final String METADATA_EXECUTOR = "metadataExecutor";
public static final String IMAGE_ANALYSIS_EXECUTOR = "imageAnalysisExecutor";
public static final String BACKGROUND_SYNC_EXECUTOR = "backgroundSyncExecutor";
public static final String HASH_EXECUTOR = "hashExecutor";
public static final String CPU_EXECUTOR = "cpuExecutor";
public static final String IO_EXECUTOR = "ioExecutor";
public static final String APPLICATION_SCHEDULER = "applicationScheduler";
@Bean(DATABASE_EXECUTOR)
@@ -40,29 +49,14 @@ public class ExecutorConfig {
return pool("pholio-db-", 1, properties);
}
@Bean(THUMBNAIL_EXECUTOR)
ThreadPoolTaskExecutor thumbnailExecutor(PholioProperties properties) {
return pool("pholio-thumb-", properties.pools().effectiveThumbnail(), properties);
@Bean(CPU_EXECUTOR)
ThreadPoolTaskExecutor cpuExecutor(PholioProperties properties) {
return pool("pholio-cpu-", properties.pools().effectiveCpu(), properties);
}
@Bean(METADATA_EXECUTOR)
ThreadPoolTaskExecutor metadataExecutor(PholioProperties properties) {
return pool("pholio-meta-", properties.pools().effectiveMetadata(), properties);
}
@Bean(IMAGE_ANALYSIS_EXECUTOR)
ThreadPoolTaskExecutor imageAnalysisExecutor(PholioProperties properties) {
return pool("pholio-ai-", properties.pools().imageAnalysis(), properties);
}
@Bean(BACKGROUND_SYNC_EXECUTOR)
ThreadPoolTaskExecutor backgroundSyncExecutor(PholioProperties properties) {
return pool("pholio-sync-", properties.pools().backgroundSync(), properties);
}
@Bean(HASH_EXECUTOR)
ThreadPoolTaskExecutor hashExecutor(PholioProperties properties) {
return pool("pholio-hash-", properties.pools().hash(), properties);
@Bean(IO_EXECUTOR)
ExecutorService ioExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/**
@@ -16,29 +16,20 @@ public record PholioProperties(@DefaultValue Pools pools,
@DefaultValue Window window) {
/**
* Thread pool sizing. Each functional area gets its own pool so a slow NAS scan can never starve
* thumbnail decoding, and neither can ever run on the JavaFX application thread.
* Thread pool sizing. Only {@code cpu} is left to size: the database pool is fixed at one thread
* (see {@code ExecutorConfig}'s javadoc) and the I/O pool is a virtual-thread-per-task executor,
* which has no thread count to pick — its concurrency scales with whatever the underlying I/O
* (disk, NAS) can actually sustain.
*/
public record Pools(// Thumbnail decoding. 0 means "one thread per core, minus one left for the UI".
@DefaultValue("0") int thumbnail,
// Metadata extraction. 0 means "one thread per core, minus one left for the UI".
@DefaultValue("0") int metadata,
@DefaultValue("1") int imageAnalysis,
@DefaultValue("1") int backgroundSync,
// Content-hashing a scanned file (see TaskType.HASH): I/O-bound, dominated by
// reading the file rather than CPU, so it profits from more concurrency than a
// core count would suggest — especially over a network share.
@DefaultValue("8") int hash,
public record Pools(// CPU-bound decode work (thumbnail generation, perceptual hash). 0 means "one
// thread per core, minus one left for the UI".
@DefaultValue("0") int cpu,
@DefaultValue("2") int scheduler,
@DefaultValue("1000000") int queueCapacity,
@DefaultValue("10s") Duration awaitTermination) {
public int effectiveThumbnail() {
return thumbnail > 0 ? thumbnail : Math.max(1, Runtime.getRuntime().availableProcessors() - 1);
}
public int effectiveMetadata() {
return metadata > 0 ? metadata : Math.max(1, Runtime.getRuntime().availableProcessors() - 1);
public int effectiveCpu() {
return cpu > 0 ? cpu : Math.max(1, Runtime.getRuntime().availableProcessors() - 1);
}
}
@@ -52,18 +52,18 @@ public class TaskService {
private final ApplicationEventPublisher publisher;
public TaskService(@Qualifier(ExecutorConfig.DATABASE_EXECUTOR) Executor database,
@Qualifier(ExecutorConfig.THUMBNAIL_EXECUTOR) Executor thumbnail,
@Qualifier(ExecutorConfig.METADATA_EXECUTOR) Executor metadata,
@Qualifier(ExecutorConfig.IMAGE_ANALYSIS_EXECUTOR) Executor imageAnalysis,
@Qualifier(ExecutorConfig.BACKGROUND_SYNC_EXECUTOR) Executor backgroundSync,
@Qualifier(ExecutorConfig.HASH_EXECUTOR) Executor hash,
@Qualifier(ExecutorConfig.CPU_EXECUTOR) Executor cpu,
@Qualifier(ExecutorConfig.IO_EXECUTOR) Executor io,
ApplicationEventPublisher publisher) {
executors.put(TaskType.DATABASE, database);
executors.put(TaskType.THUMBNAIL, thumbnail);
executors.put(TaskType.METADATA, metadata);
executors.put(TaskType.IMAGE_ANALYSIS, imageAnalysis);
executors.put(TaskType.BACKGROUND_SYNC, backgroundSync);
executors.put(TaskType.HASH, hash);
// CPU-bound, native-codec-backed decode work: kept off the virtual-thread pool below, see
// ExecutorConfig's javadoc on why.
executors.put(TaskType.THUMBNAIL, cpu);
executors.put(TaskType.IMAGE_ANALYSIS, cpu);
// I/O-bound, no native calls: free to share the virtual-thread pool.
executors.put(TaskType.METADATA, io);
executors.put(TaskType.BACKGROUND_SYNC, io);
executors.put(TaskType.HASH, io);
this.publisher = publisher;
}
@@ -1,8 +1,10 @@
package org.icroco.pholio.infra.task;
/**
* The pools {@link TaskService} can run work on, one per functional area — the same split
* {@code ExecutorConfig} already makes, named so a caller picks a pool without injecting one.
* The functional areas {@link TaskService} can run work on — named so a caller picks a lane without
* injecting an {@code Executor} itself, and without needing to know or care which of
* {@code ExecutorConfig}'s three actual pools its work lands on: several of these share a pool (see
* {@link TaskService}'s constructor), grouped by resource profile rather than one pool each.
*
* <p>Deliberately excludes the application scheduler: that pool runs recurring or debounced work
* with no single result and nothing to cancel, a different shape from the one-shot, trackable tasks
@@ -10,16 +12,19 @@ package org.icroco.pholio.infra.task;
*/
public enum TaskType {
/** Single-threaded — see {@code ExecutorConfig}'s javadoc on why. */
/** {@code ExecutorConfig#DATABASE_EXECUTOR} — single-threaded, see that class's javadoc on why. */
DATABASE,
/** {@code ExecutorConfig#CPU_EXECUTOR} — CPU-bound thumbnail decode/resize/encode. */
THUMBNAIL,
/** {@code ExecutorConfig#IO_EXECUTOR} — EXIF header reads, I/O-bound. */
METADATA,
/** {@code ExecutorConfig#CPU_EXECUTOR} — the perceptual hash, derived from an already-decoded bitmap. */
IMAGE_ANALYSIS,
/** {@code ExecutorConfig#IO_EXECUTOR} — filesystem tree scans, I/O-bound. */
BACKGROUND_SYNC,
/**
* Content-hashing a scanned file — a full-file read whose cost is dominated by I/O (a NAS share,
* typically), not CPU. Its own pool so a slow share can be read from concurrently without starving
* {@link #THUMBNAIL} decoding, the same isolation reason every other pool exists.
* {@code ExecutorConfig#IO_EXECUTOR} — content-hashing a scanned file, a full-file read whose cost
* is dominated by I/O (a NAS share, typically), not CPU.
*/
HASH
}
+4 -8
View File
@@ -56,14 +56,10 @@ pholio:
# instead — which is what the test context does.
enabled: true
pools:
# 0 => availableProcessors() - 1
thumbnail: 0
metadata: 0
image-analysis: 1
background-sync: 1
# Content-hashing a scanned file: I/O-bound (often a NAS share), so more concurrency than a core
# count would suggest is worth it — the file read is what's slow, not the digest itself.
hash: 8
# CPU-bound decode work (thumbnail generation, perceptual hash). 0 => availableProcessors() - 1.
# The database pool is fixed at 1 thread and the I/O pool is virtual-thread-per-task -- neither
# has a size to configure here, see ExecutorConfig's javadoc.
cpu: 0
scheduler: 2
queue-capacity: 1000000
await-termination: 10s
@@ -57,11 +57,8 @@ class CliContextTest {
@Test
void allThreadPoolsArePresent() {
assertThat(context.containsBean(ExecutorConfig.DATABASE_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.THUMBNAIL_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.METADATA_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.IMAGE_ANALYSIS_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.BACKGROUND_SYNC_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.HASH_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.CPU_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.IO_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.APPLICATION_SCHEDULER)).isTrue();
}
@@ -123,7 +123,7 @@ class LibraryFolderServiceTest {
events = new CopyOnWriteArrayList<>();
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, events::add);
TaskService taskService = new TaskService(inert, inert, inert, events::add);
IReverseGeocoder reverseGeocoder = locations -> locations.stream()
.collect(java.util.stream.Collectors.toMap(location -> location, location -> STUB_PLACE_NAME));
@@ -85,7 +85,7 @@ class MediaAnalysisServiceTest {
events = new ArrayList<>();
Executor inert = Runnable::run;
taskService = new TaskService(inert, inert, inert, inert, inert, inert, events::add);
taskService = new TaskService(inert, inert, inert, events::add);
service = new MediaAnalysisService(formats, thumbnailGenerator, perceptualHasher, preferences,
mediaFileRepository, taskService, events::add);
}
@@ -101,7 +101,7 @@ class MediaMetadataEditServiceTest {
when(mediaFileRepository.findById(MEDIA_FILE_ID)).thenReturn(Optional.of(mediaFileEntity));
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, publisher);
TaskService taskService = new TaskService(inert, inert, inert, publisher);
service = new MediaMetadataEditService(metadataRepository, metadataMapper, mediaFileRepository,
libraryFolderService, formatRegistry, hasher, taskService, publisher,
@@ -21,7 +21,7 @@ class NotificationRetentionSchedulerTest {
private NotificationRetentionScheduler schedulerFor(AppPreferences preferences) {
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
TaskService taskService = new TaskService(inert, inert, inert, event -> {});
return new NotificationRetentionScheduler(notificationService, preferences, taskService);
}
@@ -21,7 +21,7 @@ class StartupTaskRunnerTest {
IStartupTask taskOne = ran::countDown;
IStartupTask taskTwo = ran::countDown;
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
TaskService taskService = new TaskService(inert, inert, inert, event -> {});
StartupTaskRunner runner = spy(new StartupTaskRunner(List.of(taskOne, taskTwo), taskService));
doNothing().when(runner).waitRandomlyToStart();
@@ -38,7 +38,7 @@ class StartupTaskRunnerTest {
IStartupTask first = orderedTask(runOrder, "first", ran);
IStartupTask middle = orderedTask(runOrder, "middle", ran);
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
TaskService taskService = new TaskService(inert, inert, inert, event -> {});
StartupTaskRunner runner = spy(new StartupTaskRunner(List.of(last, first, middle), taskService));
doNothing().when(runner).waitRandomlyToStart();
@@ -46,7 +46,7 @@ class LibraryImportCoordinatorTest {
libraryFolderService = mock(LibraryFolderService.class);
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
TaskService taskService = new TaskService(inert, inert, inert, event -> {});
ModalService modalService = new ModalService(null);
coordinator = new LibraryImportCoordinator(i18n, preferences, scanner, libraryFolderService,
@@ -48,7 +48,7 @@ class NotificationsManagerTest {
void setUp() {
FxTestToolkit.requireToolkit();
pool = Executors.newCachedThreadPool();
taskService = new TaskService(pool, pool, pool, pool, pool, pool, event -> {});
taskService = new TaskService(pool, pool, pool, event -> {});
notificationService = mock(NotificationService.class);
I18nService i18n = FxTestToolkit.i18n(PreferencesFixture.fromBundledSchema());
manager = new NotificationsManager(notificationService, taskService, i18n);
@@ -77,7 +77,7 @@ class LibraryFolderTreeTest {
command.run();
};
Executor inert = Runnable::run;
taskService = new TaskService(inert, inert, inert, inert, counting, inert, event -> {});
taskService = new TaskService(inert, inert, counting, event -> {});
searchState = new GallerySearchState();
publisher = mock(ApplicationEventPublisher.class);
section = onFxThread(() -> new LibraryFolderTree(i18n, libraryFolderService, taskService,
@@ -48,7 +48,7 @@ class TaskManagerTest {
void setUp() {
FxTestToolkit.requireToolkit();
pool = Executors.newCachedThreadPool();
taskService = new TaskService(pool, pool, pool, pool, pool, pool, this::dispatch);
taskService = new TaskService(pool, pool, pool, this::dispatch);
manager = new TaskManager(taskService, this::dispatch);
}
@@ -44,7 +44,7 @@ class FullImageCacheTest {
FxTestToolkit.requireToolkit();
preferences = PreferencesFixture.fromBundledSchema();
Executor inert = Runnable::run;
taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
taskService = new TaskService(inert, inert, inert, event -> {});
cache = onFxThread(() -> new FullImageCache(preferences, taskService));
}
@@ -180,7 +180,7 @@ class GalleryViewTest {
MediaLibraryState state = new MediaLibraryState();
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
TaskService taskService = new TaskService(inert, inert, inert, event -> {});
MediaFileService mediaFileService = mock(MediaFileService.class);
when(mediaFileService.list()).thenReturn(files);
AppPreferences localPreferences = PreferencesFixture.fromBundledSchema();
@@ -56,7 +56,7 @@ class OrientedFullImageLoaderTest {
FxTestToolkit.requireToolkit();
AppPreferences preferences = PreferencesFixture.fromBundledSchema();
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
TaskService taskService = new TaskService(inert, inert, inert, event -> {});
FullImageCache fullImageCache = onFxThread(() -> new FullImageCache(preferences, taskService));
libraryFolderService = mock(LibraryFolderService.class);
ThumbnailGenerator thumbnailGenerator = new ThumbnailGenerator(mock(MediaFormatRegistry.class));
@@ -86,7 +86,7 @@ class ThumbnailGalleryPaneTest {
state = new MediaLibraryState();
Executor inert = Runnable::run;
TaskService taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
TaskService taskService = new TaskService(inert, inert, inert, event -> {});
preferences = PreferencesFixture.fromBundledSchema();
preferences.setValue("gallery", "thumbnail-size", 100);
I18nService i18n = FxTestToolkit.i18n(preferences);
@@ -45,7 +45,7 @@ class ThumbnailImageCacheTest {
FxTestToolkit.requireToolkit();
preferences = PreferencesFixture.fromBundledSchema();
Executor inert = Runnable::run;
taskService = new TaskService(inert, inert, inert, inert, inert, inert, event -> {});
taskService = new TaskService(inert, inert, inert, event -> {});
cache = onFxThread(() -> new ThumbnailImageCache(preferences, taskService));
}