Add event handling for attachment changes and update thumbnail service integration

feat/add-thumbnail-router
John Niang 2025-09-29 15:54:23 +08:00
parent 68e80d16ed
commit 230550d0df
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
7 changed files with 163 additions and 411 deletions

View File

@ -0,0 +1,22 @@
package run.halo.app.core.attachment;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import run.halo.app.core.extension.attachment.Attachment;
/**
* Event triggered when an attachment is created, updated, or deleted.
*
* @author johnniang
*/
public class AttachmentChangedEvent extends ApplicationEvent {
@Getter
private final Attachment attachment;
public AttachmentChangedEvent(Object source, Attachment attachment) {
super(source);
this.attachment = attachment;
}
}

View File

@ -1,33 +1,32 @@
package run.halo.app.core.attachment;
import java.net.URI;
import java.util.Map;
import reactor.core.publisher.Mono;
import run.halo.app.infra.ExternalLinkProcessor;
/**
* Service for managing thumbnails.
*
* @author johnniang
* @since 2.22.0
*/
public interface ThumbnailService {
/**
* Generate thumbnail by the given image uri and size.
* <p>if the imageUri is not absolute, it will be processed by {@link ExternalLinkProcessor}
* .</p>
* <p>if externalUrl is not configured, it will return empty.</p>
* Get the thumbnail link for the given image URI and size.
*
* @param imageUri image uri to generate thumbnail
* @param size thumbnail size to generate
* @return generated thumbnail uri if success, otherwise empty.
* @param permalink the permalink of the image
* @param size the size of the thumbnail
* @return the thumbnail link
*/
Mono<URI> generate(URI imageUri, ThumbnailSize size);
Mono<URI> get(URI permalink, ThumbnailSize size);
/**
* <p>Get thumbnail by the given image uri and size.</p>
* <p>It depends on the {@link #generate(URI, ThumbnailSize)} method, currently the thumbnail
* generation is limited to the attachment service, that is, the thumbnail is strongly
* associated with the attachment.</p>
* Get all thumbnail links for the given image URI.
*
* @return if thumbnail exists, return the thumbnail uri, otherwise return the original image
* uri
* @param permalink the permalink of the image
* @return the map of thumbnail size to thumbnail link
*/
Mono<URI> get(URI imageUri, ThumbnailSize size);
Mono<Map<ThumbnailSize, URI>> get(URI permalink);
Mono<Void> delete(URI imageUri);
}

View File

@ -1,162 +1,100 @@
package run.halo.app.core.attachment.impl;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.startsWith;
import java.net.MalformedURLException;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.net.URI;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Sort;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.AttachmentUtils;
import run.halo.app.core.attachment.LocalThumbnailService;
import run.halo.app.core.attachment.ThumbnailProvider;
import run.halo.app.core.attachment.ThumbnailProvider.ThumbnailContext;
import run.halo.app.core.attachment.AttachmentChangedEvent;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSigner;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.attachment.extension.Thumbnail;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.extension.index.query.QueryFactory;
@Slf4j
@Component
@RequiredArgsConstructor
public class ThumbnailServiceImpl implements ThumbnailService {
private final ExtensionGetter extensionGetter;
class ThumbnailServiceImpl implements ThumbnailService {
private final Cache<String, Map<ThumbnailSize, URI>> thumbnailCache;
private final ReactiveExtensionClient client;
private final ExternalLinkProcessor externalLinkProcessor;
private final ThumbnailProvider thumbnailProvider;
private final LocalThumbnailService localThumbnailService;
private final Map<CacheKey, Mono<URI>> ongoingTasks = new ConcurrentHashMap<>();
@Override
public Mono<URI> generate(URI imageUri, ThumbnailSize size) {
var cacheKey = new CacheKey(imageUri, size);
// Combine caching to implement more elegant deduplication logic, ensure that only
// one thread executes the logic of create at the same time, and there is no global lock
// restriction
return ongoingTasks.computeIfAbsent(cacheKey, k -> doGenerate(imageUri, size)
// In the case of concurrency, doGenerate must return the same instance
.doFinally(signalType -> ongoingTasks.remove(cacheKey))
.cache()
);
}
record CacheKey(URI imageUri, ThumbnailSize size) {
}
private Mono<URI> doGenerate(URI imageUri, ThumbnailSize size) {
var imageUrlOpt = toImageUrl(imageUri);
if (imageUrlOpt.isEmpty()) {
return Mono.empty();
}
var imageUrl = imageUrlOpt.get();
return fetchThumbnail(imageUri, size)
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
.switchIfEmpty(Mono.defer(() -> create(imageUrl, size)))
.onErrorResume(Throwable.class, e -> {
log.warn("Failed to generate thumbnail for image: {}", imageUrl, e);
return Mono.just(URI.create(imageUrl.toString()));
});
}
@Override
public Mono<URI> get(URI imageUri, ThumbnailSize size) {
return fetchThumbnail(imageUri, size)
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
.defaultIfEmpty(imageUri);
}
@Override
public Mono<Void> delete(URI imageUri) {
Assert.notNull(imageUri, "Image uri must not be null");
Mono<Void> deleteMono;
if (imageUri.isAbsolute()) {
deleteMono = thumbnailProvider.delete(AttachmentUtils.toUrl(imageUri));
} else {
// Local thumbnails maybe a relative path, so we need to process it.
deleteMono = localThumbnailService.delete(imageUri);
}
return deleteMono.then(deleteThumbnailRecord(imageUri));
}
private Mono<Void> deleteThumbnailRecord(URI imageUri) {
var imageHash = signatureFor(imageUri);
var listOptions = ListOptions.builder()
.fieldQuery(startsWith(Thumbnail.ID_INDEX, Thumbnail.idIndexFunc(imageHash, "")))
public ThumbnailServiceImpl(ReactiveExtensionClient client) {
this.client = client;
this.thumbnailCache = Caffeine.newBuilder()
.maximumSize(10_000)
.build();
return client.listAll(Thumbnail.class, listOptions, Sort.unsorted())
.flatMap(client::delete)
.then();
}
Optional<URL> toImageUrl(URI imageUri) {
try {
if (imageUri.isAbsolute()) {
return Optional.of(imageUri.toURL());
@EventListener
void handleAttachmentChangedEvent(AttachmentChangedEvent event) {
invalidateOrUpdateCache(event.getAttachment());
}
void invalidateOrUpdateCache(Attachment attachment) {
if (attachment.getStatus() == null) {
return;
}
var permalink = attachment.getStatus().getPermalink();
if (!StringUtils.hasText(permalink)) {
return;
}
if (ExtensionUtil.isDeleted(attachment)) {
thumbnailCache.invalidate(permalink);
return;
}
var thumbnails = attachment.getStatus().getThumbnails();
if (CollectionUtils.isEmpty(thumbnails)) {
thumbnailCache.put(permalink, Map.of());
return;
}
Map<ThumbnailSize, URI> validThumbnails = new HashMap<>();
thumbnails.forEach((key, value) -> {
var size = ThumbnailSize.optionalValueOf(key);
if (size.isPresent() && StringUtils.hasText(value)) {
validThumbnails.put(size.get(), URI.create(value));
}
var url = new URL(externalLinkProcessor.processLink(imageUri.toString()));
return Optional.of(url);
} catch (MalformedURLException e) {
// Ignore
});
if (validThumbnails.isEmpty()) {
thumbnailCache.put(permalink, Map.of());
} else {
thumbnailCache.put(permalink, validThumbnails);
}
return Optional.empty();
}
protected Mono<URI> create(URL imageUrl, ThumbnailSize size) {
var context = ThumbnailContext.builder()
.imageUrl(imageUrl)
.size(size)
@Override
public Mono<URI> get(URI permalink, ThumbnailSize size) {
return get(permalink).mapNotNull(thumbnails -> thumbnails.get(size));
}
@Override
public Mono<Map<ThumbnailSize, URI>> get(URI permalink) {
var permalinkString = permalink.toASCIIString();
var thumbnails = thumbnailCache.getIfPresent(permalinkString);
if (thumbnails != null) {
return Mono.just(thumbnails);
}
// query from attachments
var listOptions = ListOptions.builder()
.andQuery(QueryFactory.equal("status.permalink", permalinkString))
.build();
var imageUri =
localThumbnailService.ensureInSiteUriIsRelative(URI.create(imageUrl.toString()));
return extensionGetter.getEnabledExtensions(ThumbnailProvider.class)
.filterWhen(provider -> provider.supports(context))
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
.next()
.flatMap(provider -> provider.generate(context))
.flatMap(uri -> {
var thumb = new Thumbnail();
thumb.setMetadata(new Metadata());
thumb.getMetadata().setGenerateName("thumb-");
thumb.setSpec(new Thumbnail.Spec()
.setSize(size)
.setThumbnailUri(uri.toASCIIString())
.setImageUri(imageUri.toASCIIString())
.setImageSignature(signatureFor(imageUri))
);
// double check
return fetchThumbnail(imageUri, size)
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
.switchIfEmpty(Mono.defer(() -> client.create(thumb)
.thenReturn(uri))
);
.map(attachment -> {
// Here we allow concurrent updates
invalidateOrUpdateCache(attachment);
return this.thumbnailCache.getIfPresent(permalinkString);
});
}
private String signatureFor(URI imageUri) {
var uri = localThumbnailService.ensureInSiteUriIsRelative(imageUri);
return ThumbnailSigner.generateSignature(uri);
}
Mono<Thumbnail> fetchThumbnail(URI imageUri, ThumbnailSize size) {
var imageHash = signatureFor(imageUri);
var id = Thumbnail.idIndexFunc(imageHash, size.name());
return client.listBy(Thumbnail.class, ListOptions.builder()
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
.build(), PageRequestImpl.ofSize(1))
.flatMap(result -> Mono.justOrEmpty(ListResult.first(result)));
}
}

View File

@ -5,13 +5,13 @@ import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import java.net.URI;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.AttachmentChangedEvent;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus;
import run.halo.app.core.extension.attachment.Constant;
@ -33,7 +33,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
private final AttachmentService attachmentService;
private final ThumbnailService thumbnailService;
private final ApplicationEventPublisher eventPublisher;
@Override
public Result reconcile(Request request) {
@ -43,6 +43,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
Set.of(Constant.FINALIZER_NAME))) {
cleanUpResources(attachment);
client.update(attachment);
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
}
return;
}
@ -74,6 +75,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
attachment.getStatus().setThumbnails(thumbnails);
log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name());
client.update(attachment);
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
});
return null;
}
@ -86,12 +88,6 @@ public class AttachmentReconciler implements Reconciler<Request> {
}
void cleanUpResources(Attachment attachment) {
var timeout = Duration.ofSeconds(20);
Optional.ofNullable(attachment.getStatus())
.map(AttachmentStatus::getPermalink)
.map(URI::create)
.ifPresent(uri -> thumbnailService.delete(uri).block(timeout));
attachmentService.delete(attachment).block(timeout);
attachmentService.delete(attachment).block(Duration.ofSeconds(20));
}
}

View File

@ -20,14 +20,11 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
/**
* Thumbnail endpoint for thumbnail resource access.
@ -39,6 +36,8 @@ import run.halo.app.extension.index.query.QueryFactory;
@RequiredArgsConstructor
public class ThumbnailEndpoint implements CustomEndpoint {
private final ThumbnailService thumbnailService;
private final ReactiveExtensionClient client;
@Override
@ -49,32 +48,49 @@ public class ThumbnailEndpoint implements CustomEndpoint {
builder.operationId("GetThumbnailByUri")
.description("Get thumbnail by URI")
.tag(tag)
.response(responseBuilder()
.implementation(Resource.class));
ThumbnailQuery.buildParameters(builder);
.response(responseBuilder().implementation(Resource.class))
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("uri")
.description("The URI of the image")
.required(true)
)
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("size")
.implementation(ThumbnailSize.class)
.description("The size of the thumbnail")
.required(true)
);
})
.build();
}
public ThumbnailService getThumbnailService() {
return thumbnailService;
}
private Mono<ServerResponse> getThumbnailByUri(ServerRequest request) {
var query = new ThumbnailQuery(request.queryParams());
var size = query.getSize();
var uri = query.getUri().toASCIIString();
var listOptions = ListOptions.builder()
.andQuery(ExtensionUtil.notDeleting())
.andQuery(QueryFactory.equal("status.permalink", uri))
.build();
// query by permalink
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
// find the first one
.next()
.mapNotNull(attachment -> {
var thumbnails = attachment.getStatus().getThumbnails();
return thumbnails.get(size.name());
})
.defaultIfEmpty(uri)
var uri = request.queryParam("uri")
.filter(StringUtils::isNotBlank)
.map(URI::create);
if (uri.isEmpty()) {
return Mono.error(
new ServerWebInputException("Required parameter 'uri' is missing or invalid")
);
}
var size = request.queryParam("size")
.filter(StringUtils::isNotBlank)
.flatMap(ThumbnailSize::optionalValueOf);
if (size.isEmpty()) {
return Mono.error(
new ServerWebInputException("Required parameter 'size' is missing or invalid")
);
}
return thumbnailService.get(uri.get(), size.get())
.defaultIfEmpty(uri.get())
.flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND)
.location(URI.create(thumbnailLink))
.location(thumbnailLink)
.build()
);
}

View File

@ -1,8 +1,11 @@
package run.halo.app.theme.finders.impl;
import java.net.URI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.ThumbnailFinder;
@ -11,9 +14,12 @@ import run.halo.app.theme.finders.ThumbnailFinder;
@RequiredArgsConstructor
public class ThumbnailFinderImpl implements ThumbnailFinder {
private final ThumbnailService thumbnailService;
@Override
public Mono<String> gen(String uriStr, String size) {
// TODO Implement me
return Mono.just(uriStr);
return thumbnailService.get(URI.create(uriStr), ThumbnailSize.fromName(size))
.map(URI::toASCIIString);
}
}

View File

@ -1,225 +0,0 @@
package run.halo.app.core.attachment.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.assertArg;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.attachment.LocalThumbnailProvider;
import run.halo.app.core.attachment.LocalThumbnailService;
import run.halo.app.core.attachment.ThumbnailProvider;
import run.halo.app.core.attachment.ThumbnailSigner;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.attachment.extension.Thumbnail;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
/**
* Tests for {@link ThumbnailServiceImpl}.
*
* @author guqing
* @since 2.19.0
*/
@ExtendWith(MockitoExtension.class)
class ThumbnailServiceImplTest {
@Mock
private ExternalLinkProcessor externalLinkProcessor;
@Mock
private ExtensionGetter extensionGetter;
@Mock
private LocalThumbnailProvider localThumbnailProvider;
@Mock
private LocalThumbnailService localThumbnailService;
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private ThumbnailServiceImpl thumbnailService;
@Test
void toImageUrl() {
var link = "/test.jpg";
when(externalLinkProcessor.processLink(link)).thenReturn("http://localhost:8090/test.jpg");
var imageUrl = thumbnailService.toImageUrl(URI.create(link));
assertThat(imageUrl).isPresent();
assertThat(imageUrl.get().toString()).isEqualTo("http://localhost:8090/test.jpg");
var absoluteLink = "https://halo.run/test.jpg";
imageUrl = thumbnailService.toImageUrl(URI.create(absoluteLink));
assertThat(imageUrl).isPresent();
assertThat(imageUrl.get().toString()).isEqualTo(absoluteLink);
}
@Test
void generateTest() {
var uri = URI.create("http://localhost:8090/test.jpg");
var size = ThumbnailSize.L;
when(localThumbnailService.ensureInSiteUriIsRelative(eq(uri)))
.thenReturn(uri);
var imageHash = ThumbnailSigner.generateSignature(uri.toString());
var id = Thumbnail.idIndexFunc(imageHash, size.name());
var listOptions = ListOptions.builder()
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
.build();
when(client.listBy(eq(Thumbnail.class), any(), any())).thenReturn(Mono.empty());
var spyThumbnailService = spy(thumbnailService);
doReturn(Mono.empty()).when(spyThumbnailService).create(any(), any());
spyThumbnailService.generate(uri, size)
.as(StepVerifier::create)
.verifyComplete();
verify(client).listBy(eq(Thumbnail.class), assertArg(options -> {
assertThat(options.toString()).isEqualTo(listOptions.toString());
}), isA(PageRequest.class));
}
@Test
void createTest() throws MalformedURLException, URISyntaxException {
var url = new URL("http://localhost:8090/test.jpg");
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
.thenReturn(Flux.just(localThumbnailProvider));
var thumbUri = URI.create("/test-thumb.jpg");
when(localThumbnailProvider.generate(any())).thenReturn(Mono.just(thumbUri));
when(localThumbnailProvider.supports(any())).thenReturn(Mono.just(true));
var insiteUri = URI.create("/test.jpg");
when(localThumbnailService.ensureInSiteUriIsRelative(any()))
.thenReturn(insiteUri);
when(client.create(any())).thenReturn(Mono.empty());
when(client.listBy(eq(Thumbnail.class), any(), isA(PageRequest.class)))
.thenReturn(Mono.empty());
thumbnailService.create(url, ThumbnailSize.M)
.as(StepVerifier::create)
.expectNext(thumbUri)
.verifyComplete();
thumbnailService.fetchThumbnail(url.toURI(), ThumbnailSize.M)
.as(StepVerifier::create)
.verifyComplete();
var hash = ThumbnailSigner.generateSignature(insiteUri.toString());
verify(client, times(2)).listBy(eq(Thumbnail.class),
assertArg(options -> {
var exceptOptions = ListOptions.builder()
.fieldQuery(equal(Thumbnail.ID_INDEX,
Thumbnail.idIndexFunc(hash, ThumbnailSize.M.name())
))
.build();
assertThat(options.toString()).isEqualTo(exceptOptions.toString());
}), isA(PageRequest.class));
verify(localThumbnailProvider).generate(any());
verify(client).create(assertArg(thumb -> {
JSONAssert.assertEquals("""
{
"spec": {
"imageSignature": "%s",
"imageUri": "/test.jpg",
"size": "M",
"thumbnailUri": "/test-thumb.jpg"
},
"apiVersion": "storage.halo.run/v1alpha1",
"kind": "Thumbnail",
"metadata": {
"generateName": "thumb-"
}
}
""".formatted(hash), JsonUtils.objectToJson(thumb), true);
}));
}
@Test
void createTest2() throws MalformedURLException {
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
.thenReturn(Flux.empty());
// no thumbnail provider will do nothing
var url = new URL("http://localhost:8090/test.jpg");
thumbnailService.create(url, ThumbnailSize.M)
.as(StepVerifier::create)
.verifyComplete();
}
@Nested
class ThumbnailGenerateConcurrencyTest {
@Test
public void concurrentThumbnailGeneration() throws InterruptedException {
var spyThumbnailService = spy(thumbnailService);
URI imageUri = URI.create("http://localhost:8090/test.jpg");
doReturn(Mono.empty()).when(spyThumbnailService).fetchThumbnail(eq(imageUri), any());
var createdUri = URI.create("/test-thumb.jpg");
doReturn(Mono.just(createdUri)).when(spyThumbnailService).create(any(), any());
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
var latch = new CountDownLatch(threadCount);
var results = new ConcurrentLinkedQueue<Mono<URI>>();
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
results.add(spyThumbnailService.generate(imageUri, ThumbnailSize.M));
} finally {
latch.countDown();
}
});
}
latch.await();
results.forEach(result -> {
StepVerifier.create(result)
.expectNext(createdUri)
.verifyComplete();
});
verify(spyThumbnailService).fetchThumbnail(eq(imageUri), eq(ThumbnailSize.M));
verify(spyThumbnailService).create(any(), eq(ThumbnailSize.M));
executor.shutdown();
}
}
}