From 230550d0dfac056b27079632c43053eb5cf94196 Mon Sep 17 00:00:00 2001 From: John Niang Date: Mon, 29 Sep 2025 15:54:23 +0800 Subject: [PATCH] Add event handling for attachment changes and update thumbnail service integration --- .../attachment/AttachmentChangedEvent.java | 22 ++ .../app/core/attachment/ThumbnailService.java | 33 ++- .../attachment/impl/ThumbnailServiceImpl.java | 204 ++++++---------- .../reconciler/AttachmentReconciler.java | 16 +- .../endpoint/theme/ThumbnailEndpoint.java | 64 +++-- .../finders/impl/ThumbnailFinderImpl.java | 10 +- .../impl/ThumbnailServiceImplTest.java | 225 ------------------ 7 files changed, 163 insertions(+), 411 deletions(-) create mode 100644 application/src/main/java/run/halo/app/core/attachment/AttachmentChangedEvent.java delete mode 100644 application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java diff --git a/application/src/main/java/run/halo/app/core/attachment/AttachmentChangedEvent.java b/application/src/main/java/run/halo/app/core/attachment/AttachmentChangedEvent.java new file mode 100644 index 000000000..6eee3fa15 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/AttachmentChangedEvent.java @@ -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; + } + +} diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailService.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailService.java index e6e5d8500..5870f5b9f 100644 --- a/application/src/main/java/run/halo/app/core/attachment/ThumbnailService.java +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailService.java @@ -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. - *

if the imageUri is not absolute, it will be processed by {@link ExternalLinkProcessor} - * .

- *

if externalUrl is not configured, it will return empty.

+ * 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 generate(URI imageUri, ThumbnailSize size); + Mono get(URI permalink, ThumbnailSize size); /** - *

Get thumbnail by the given image uri and size.

- *

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.

+ * 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 get(URI imageUri, ThumbnailSize size); + Mono> get(URI permalink); - Mono delete(URI imageUri); } diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java index d77a94ba9..cb45924a4 100644 --- a/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java @@ -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> thumbnailCache; + private final ReactiveExtensionClient client; - private final ExternalLinkProcessor externalLinkProcessor; - private final ThumbnailProvider thumbnailProvider; - private final LocalThumbnailService localThumbnailService; - private final Map> ongoingTasks = new ConcurrentHashMap<>(); - @Override - public Mono 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 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 get(URI imageUri, ThumbnailSize size) { - return fetchThumbnail(imageUri, size) - .map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri())) - .defaultIfEmpty(imageUri); - } - - @Override - public Mono delete(URI imageUri) { - Assert.notNull(imageUri, "Image uri must not be null"); - Mono 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 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 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 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 create(URL imageUrl, ThumbnailSize size) { - var context = ThumbnailContext.builder() - .imageUrl(imageUrl) - .size(size) + @Override + public Mono get(URI permalink, ThumbnailSize size) { + return get(permalink).mapNotNull(thumbnails -> thumbnails.get(size)); + } + + @Override + public Mono> 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 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))); - } } diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java index 003f206aa..62b9723fd 100644 --- a/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java @@ -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 { 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 { 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 { 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 { } 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)); } } diff --git a/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java index 494ad0b22..16e4cadd6 100644 --- a/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java @@ -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 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() ); } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java index 8c529715a..774a14473 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java @@ -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 gen(String uriStr, String size) { - // TODO Implement me - return Mono.just(uriStr); + return thumbnailService.get(URI.create(uriStr), ThumbnailSize.fromName(size)) + .map(URI::toASCIIString); } + } diff --git a/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java b/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java deleted file mode 100644 index c1c1433cc..000000000 --- a/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java +++ /dev/null @@ -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>(); - - 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(); - } - } -} \ No newline at end of file