Fix the problem of being able to search deleted posts (#3877)

#### What type of PR is this?

/kind bug
/kind improvement
/area core

#### What this PR does / why we need it:

This PR refactors post reconciler to reduce post updates and refines post events.

Previously, we need 3 - 4 updates per reconciliation, but now we only need 1. And all events collected in reconciler will be fired after updating post.

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/3121

#### Special notes for your reviewer:

0. Install search plugin
1. Create a public post and publish it
2. Search posts
3. Try to make the post private
4. Search posts
5. Try to make the post public
6. Search posts
7. Try to delete the post
8. Search posts
9. Try to recover the post
10. Search posts

#### Does this PR introduce a user-facing change?

```release-note
修复依然能搜索到已删除文章的问题
```
pull/3919/head^2
John Niang 2023-05-09 10:49:43 +08:00 committed by GitHub
parent 0564c5dc35
commit 3b61807e8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 380 additions and 360 deletions

View File

@ -8,4 +8,6 @@ public enum Constant {
public static final String LAST_READ_TIME_ANNO = "content.halo.run/last-read-time";
public static final String PERMALINK_PATTERN_ANNO = "content.halo.run/permalink-pattern";
public static final String CHECKSUM_CONFIG_ANNO = "checksum/config";
}

View File

@ -97,7 +97,6 @@ public interface ExtensionOperator {
}
static boolean isDeleted(ExtensionOperator extension) {
return extension.getMetadata() != null
&& extension.getMetadata().getDeletionTimestamp() != null;
return ExtensionUtil.isDeleted(extension);
}
}

View File

@ -0,0 +1,31 @@
package run.halo.app.extension;
import java.util.HashSet;
import java.util.Set;
public enum ExtensionUtil {
;
public static boolean isDeleted(ExtensionOperator extension) {
return extension.getMetadata() != null
&& extension.getMetadata().getDeletionTimestamp() != null;
}
public static void addFinalizers(MetadataOperator metadata, Set<String> finalizers) {
var existingFinalizers = metadata.getFinalizers();
if (existingFinalizers == null) {
existingFinalizers = new HashSet<>();
}
existingFinalizers.addAll(finalizers);
metadata.setFinalizers(existingFinalizers);
}
public static void removeFinalizers(MetadataOperator metadata, Set<String> finalizers) {
var existingFinalizers = metadata.getFinalizers();
if (existingFinalizers != null) {
existingFinalizers.removeAll(finalizers);
}
metadata.setFinalizers(existingFinalizers);
}
}

View File

@ -14,4 +14,6 @@ public interface PostSearchService extends ExtensionPoint {
void removeDocuments(Set<String> postNames) throws Exception;
void removeAllDocuments() throws Exception;
}

View File

@ -0,0 +1,59 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
class ExtensionUtilTest {
@Test
void testIsNotDeleted() {
var ext = mock(ExtensionOperator.class);
when(ext.getMetadata()).thenReturn(null);
assertFalse(ExtensionUtil.isDeleted(ext));
var metadata = mock(Metadata.class);
when(ext.getMetadata()).thenReturn(metadata);
when(metadata.getDeletionTimestamp()).thenReturn(null);
assertFalse(ExtensionUtil.isDeleted(ext));
when(metadata.getDeletionTimestamp()).thenReturn(Instant.now());
assertTrue(ExtensionUtil.isDeleted(ext));
}
@Test
void addFinalizers() {
var metadata = new Metadata();
assertNull(metadata.getFinalizers());
ExtensionUtil.addFinalizers(metadata, Set.of("fake"));
assertEquals(Set.of("fake"), metadata.getFinalizers());
ExtensionUtil.addFinalizers(metadata, Set.of("fake"));
assertEquals(Set.of("fake"), metadata.getFinalizers());
ExtensionUtil.addFinalizers(metadata, Set.of("another-fake"));
assertEquals(Set.of("fake", "another-fake"), metadata.getFinalizers());
}
@Test
void removeFinalizers() {
var metadata = new Metadata();
ExtensionUtil.removeFinalizers(metadata, Set.of("fake"));
assertNull(metadata.getFinalizers());
metadata.setFinalizers(new HashSet<>(Set.of("fake")));
ExtensionUtil.removeFinalizers(metadata, Set.of("fake"));
assertEquals(Set.of(), metadata.getFinalizers());
}
}

View File

@ -30,8 +30,6 @@ import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.event.post.PostRecycledEvent;
import run.halo.app.event.post.PostUnpublishedEvent;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
@ -262,9 +260,6 @@ public class PostEndpoint implements CustomEndpoint {
.flatMap(client::update))
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException))
// TODO Fire unpublished event in reconciler in the future
.doOnNext(post -> eventPublisher.publishEvent(
new PostUnpublishedEvent(this, post.getMetadata().getName())))
.flatMap(post -> ServerResponse.ok().bodyValue(post));
}
@ -278,9 +273,6 @@ public class PostEndpoint implements CustomEndpoint {
.flatMap(client::update))
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException))
// TODO Fire recycled event in reconciler in the future
.doOnNext(post -> eventPublisher.publishEvent(
new PostRecycledEvent(this, post.getMetadata().getName())))
.flatMap(post -> ServerResponse.ok().bodyValue(post));
}

View File

@ -1,39 +1,45 @@
package run.halo.app.core.extension.reconciler;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import com.google.common.hash.Hashing;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostService;
import run.halo.app.content.permalinks.PostPermalinkPolicy;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Post.PostPhase;
import run.halo.app.core.extension.content.Post.VisibleEnum;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.event.post.PostPublishedEvent;
import run.halo.app.event.post.PostUnpublishedEvent;
import run.halo.app.event.post.PostUpdatedEvent;
import run.halo.app.event.post.PostVisibleChangedEvent;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.Ref;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionList;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.HaloUtils;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
@ -62,20 +68,134 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
@Override
public Result reconcile(Request request) {
var events = new HashSet<ApplicationEvent>();
client.fetch(Post.class, request.name())
.ifPresent(post -> {
if (ExtensionOperator.isDeleted(post)) {
cleanUpResourcesAndRemoveFinalizer(request.name());
removeFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
unPublishPost(post, events);
cleanUpResources(post);
// update post to be able to be collected by gc collector.
client.update(post);
// fire event after updating post
events.forEach(eventPublisher::publishEvent);
return;
}
addFinalizerIfNecessary(post);
addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
// reconcile spec first
reconcileSpec(request.name());
reconcileMetadata(request.name());
reconcileStatus(request.name());
var labels = post.getMetadata().getLabels();
if (labels == null) {
labels = new HashMap<>();
post.getMetadata().setLabels(labels);
}
var annotations = post.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
post.getMetadata().setAnnotations(annotations);
}
var status = post.getStatus();
if (status == null) {
status = new Post.PostStatus();
post.setStatus(status);
}
// calculate the sha256sum
var configSha256sum = Hashing.sha256().hashString(post.getSpec().toString(), UTF_8)
.toString();
var oldConfigChecksum = annotations.get(Constant.CHECKSUM_CONFIG_ANNO);
if (!Objects.equals(oldConfigChecksum, configSha256sum)) {
// if the checksum doesn't match
events.add(new PostUpdatedEvent(this, post.getMetadata().getName()));
annotations.put(Constant.CHECKSUM_CONFIG_ANNO, configSha256sum);
}
var expectDelete = defaultIfNull(post.getSpec().getDeleted(), false);
var expectPublish = defaultIfNull(post.getSpec().getPublish(), false);
if (expectDelete || !expectPublish) {
unPublishPost(post, events);
} else {
publishPost(post, events);
}
labels.put(Post.DELETED_LABEL, expectDelete.toString());
var expectVisible = defaultIfNull(post.getSpec().getVisible(), VisibleEnum.PUBLIC);
var oldVisible = VisibleEnum.from(labels.get(Post.VISIBLE_LABEL));
if (!Objects.equals(oldVisible, expectVisible)) {
eventPublisher.publishEvent(
new PostVisibleChangedEvent(request.name(), oldVisible, expectVisible));
}
labels.put(Post.VISIBLE_LABEL, expectVisible.toString());
var ownerName = post.getSpec().getOwner();
if (StringUtils.isNotBlank(ownerName)) {
labels.put(Post.OWNER_LABEL, ownerName);
}
var publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime));
}
var permalinkPattern = postPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, permalinkPattern);
status.setPermalink(postPermalinkPolicy.permalink(post));
if (status.getPhase() == null) {
status.setPhase(PostPhase.DRAFT.toString());
}
var excerpt = post.getSpec().getExcerpt();
if (excerpt == null) {
excerpt = new Post.Excerpt();
}
var isAutoGenerate = defaultIfNull(excerpt.getAutoGenerate(), true);
if (isAutoGenerate) {
Optional<ContentWrapper> contentWrapper =
postService.getContent(post.getSpec().getReleaseSnapshot(),
post.getSpec().getBaseSnapshot())
.blockOptional();
if (contentWrapper.isPresent()) {
String contentRevised = contentWrapper.get().getContent();
status.setExcerpt(getExcerpt(contentRevised));
}
} else {
status.setExcerpt(excerpt.getRaw());
}
var ref = Ref.of(post);
// handle contributors
var headSnapshot = post.getSpec().getHeadSnapshot();
var contributors = client.list(Snapshot.class,
snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null)
.stream()
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames,
() -> new HashSet<String>());
})
.flatMap(Set::stream)
.distinct()
.sorted()
.toList();
status.setContributors(contributors);
// update in progress status
status.setInProgress(
!StringUtils.equals(headSnapshot, post.getSpec().getReleaseSnapshot()));
client.update(post);
// fire event after updating post
events.forEach(eventPublisher::publishEvent);
});
return new Result(false, null);
return Result.doNotRetry();
}
@Override
@ -83,290 +203,78 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
return builder
.extension(new Post())
// TODO Make it configurable
.workerCount(10)
.workerCount(1)
.build();
}
private void reconcileSpec(String name) {
client.fetch(Post.class, name).ifPresent(post -> {
// un-publish post if necessary
if (post.isPublished()
&& Objects.equals(false, post.getSpec().getPublish())) {
boolean success = unPublishReconcile(name);
if (success) {
eventPublisher.publishEvent(new PostUnpublishedEvent(this, name));
}
return;
}
try {
publishPost(name);
} catch (Throwable e) {
publishFailed(name, e);
throw e;
}
});
}
private void publishPost(String name) {
client.fetch(Post.class, name)
.filter(post -> Objects.equals(true, post.getSpec().getPublish()))
.ifPresent(post -> {
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(post);
String lastReleasedSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO);
String releaseSnapshot = post.getSpec().getReleaseSnapshot();
if (StringUtils.isBlank(releaseSnapshot)) {
return;
}
// do nothing if release snapshot is not changed and post is published
if (post.isPublished()
&& StringUtils.equals(lastReleasedSnapshot, releaseSnapshot)) {
return;
}
Post.PostStatus status = post.getStatusOrDefault();
// validate release snapshot
Optional<Snapshot> releasedSnapshotOpt =
client.fetch(Snapshot.class, releaseSnapshot);
if (releasedSnapshotOpt.isEmpty()) {
Condition condition = Condition.builder()
.type(Post.PostPhase.FAILED.name())
.reason("SnapshotNotFound")
.message(
String.format("Snapshot [%s] not found for publish", releaseSnapshot))
.status(ConditionStatus.FALSE)
.lastTransitionTime(Instant.now())
.build();
status.getConditionsOrDefault().addAndEvictFIFO(condition);
status.setPhase(Post.PostPhase.FAILED.name());
client.update(post);
return;
}
// do publish
annotations.put(Post.LAST_RELEASED_SNAPSHOT_ANNO, releaseSnapshot);
status.setPhase(Post.PostPhase.PUBLISHED.name());
Condition condition = Condition.builder()
.type(Post.PostPhase.PUBLISHED.name())
.reason("Published")
.message("Post published successfully.")
.lastTransitionTime(Instant.now())
.status(ConditionStatus.TRUE)
.build();
status.getConditionsOrDefault().addAndEvictFIFO(condition);
Post.changePublishedState(post, true);
if (post.getSpec().getPublishTime() == null) {
post.getSpec().setPublishTime(Instant.now());
}
// populate lastModifyTime
status.setLastModifyTime(releasedSnapshotOpt.get().getSpec().getLastModifyTime());
client.update(post);
eventPublisher.publishEvent(new PostPublishedEvent(this, name));
});
}
private boolean unPublishReconcile(String name) {
return client.fetch(Post.class, name)
.map(post -> {
final Post oldPost = JsonUtils.deepCopy(post);
Post.changePublishedState(post, false);
final Post.PostStatus status = post.getStatusOrDefault();
Condition condition = new Condition();
condition.setType("CancelledPublish");
condition.setStatus(ConditionStatus.TRUE);
condition.setReason(condition.getType());
condition.setMessage("CancelledPublish");
condition.setLastTransitionTime(Instant.now());
status.getConditionsOrDefault().addAndEvictFIFO(condition);
status.setPhase(Post.PostPhase.DRAFT.name());
if (!oldPost.equals(post)) {
client.update(post);
}
return true;
})
.orElse(false);
}
private void publishFailed(String name, Throwable error) {
Assert.notNull(name, "Name must not be null");
Assert.notNull(error, "Error must not be null");
client.fetch(Post.class, name).ifPresent(post -> {
final Post oldPost = JsonUtils.deepCopy(post);
Post.PostStatus status = post.getStatusOrDefault();
Post.PostPhase phase = Post.PostPhase.FAILED;
status.setPhase(phase.name());
final ConditionList conditions = status.getConditionsOrDefault();
private void publishPost(Post post, Set<ApplicationEvent> events) {
var expectReleaseSnapshot = post.getSpec().getReleaseSnapshot();
if (StringUtils.isBlank(expectReleaseSnapshot)) {
// Do nothing if release snapshot is not set
return;
}
var annotations = post.getMetadata().getAnnotations();
var lastReleaseSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO);
if (post.isPublished()
&& Objects.equals(expectReleaseSnapshot, lastReleaseSnapshot)) {
// If the release snapshot is not change
return;
}
var status = post.getStatus();
// validate the release snapshot
var snapshot = client.fetch(Snapshot.class, expectReleaseSnapshot);
if (snapshot.isEmpty()) {
Condition condition = Condition.builder()
.type(phase.name())
.reason("PublishFailed")
.message(error.getMessage())
.type(PostPhase.FAILED.name())
.reason("SnapshotNotFound")
.message(
String.format("Snapshot [%s] not found for publish", expectReleaseSnapshot))
.status(ConditionStatus.FALSE)
.lastTransitionTime(Instant.now())
.build();
conditions.addAndEvictFIFO(condition);
post.setStatus(status);
if (!oldPost.equals(post)) {
client.update(post);
}
});
}
private void reconcileMetadata(String name) {
client.fetch(Post.class, name).ifPresent(post -> {
final Post oldPost = JsonUtils.deepCopy(post);
Post.PostSpec spec = post.getSpec();
// handle logic delete
Map<String, String> labels = MetadataUtil.nullSafeLabels(post);
if (Objects.equals(spec.getDeleted(), true)) {
labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString());
} else {
labels.put(Post.DELETED_LABEL, Boolean.FALSE.toString());
}
fireVisibleChangedEventIfChanged(post);
labels.put(Post.VISIBLE_LABEL,
Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name());
labels.put(Post.OWNER_LABEL, spec.getOwner());
Instant publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime));
}
if (!labels.containsKey(Post.PUBLISHED_LABEL)) {
labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString());
}
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(post);
String newPattern = postPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern);
if (!oldPost.equals(post)) {
client.update(post);
}
});
}
private void fireVisibleChangedEventIfChanged(Post post) {
status.getConditionsOrDefault().addAndEvictFIFO(condition);
status.setPhase(PostPhase.FAILED.name());
return;
}
annotations.put(Post.LAST_RELEASED_SNAPSHOT_ANNO, expectReleaseSnapshot);
status.setPhase(PostPhase.PUBLISHED.toString());
var condition = Condition.builder()
.type(PostPhase.PUBLISHED.name())
.reason("Published")
.message("Post published successfully.")
.lastTransitionTime(Instant.now())
.status(ConditionStatus.TRUE)
.build();
status.getConditionsOrDefault().addAndEvictFIFO(condition);
var labels = post.getMetadata().getLabels();
if (labels == null) {
labels.put(Post.PUBLISHED_LABEL, Boolean.TRUE.toString());
if (post.getSpec().getPublishTime() == null) {
// TODO Set the field in creation hook in the future.
post.getSpec().setPublishTime(Instant.now());
}
status.setLastModifyTime(snapshot.get().getSpec().getLastModifyTime());
events.add(new PostPublishedEvent(this, post.getMetadata().getName()));
}
void unPublishPost(Post post, Set<ApplicationEvent> events) {
if (!post.isPublished()) {
return;
}
var name = post.getMetadata().getName();
var oldVisibleStr = labels.get(Post.VISIBLE_LABEL);
if (oldVisibleStr != null) {
var oldVisible = Post.VisibleEnum.valueOf(oldVisibleStr);
var expectVisible = post.getSpec().getVisible();
if (!Objects.equals(oldVisible, expectVisible)) {
eventPublisher.publishEvent(
new PostVisibleChangedEvent(name, oldVisible, expectVisible));
}
}
}
var labels = post.getMetadata().getLabels();
labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString());
var status = post.getStatus();
private void reconcileStatus(String name) {
client.fetch(Post.class, name).ifPresent(post -> {
final Post oldPost = JsonUtils.deepCopy(post);
var condition = new Condition();
condition.setType("CancelledPublish");
condition.setStatus(ConditionStatus.TRUE);
condition.setReason(condition.getType());
condition.setMessage("CancelledPublish");
condition.setLastTransitionTime(Instant.now());
status.getConditionsOrDefault().addAndEvictFIFO(condition);
post.getStatusOrDefault()
.setPermalink(postPermalinkPolicy.permalink(post));
status.setPhase(PostPhase.DRAFT.toString());
Post.PostStatus status = post.getStatusOrDefault();
if (status.getPhase() == null) {
status.setPhase(Post.PostPhase.DRAFT.name());
}
Post.PostSpec spec = post.getSpec();
// handle excerpt
Post.Excerpt excerpt = spec.getExcerpt();
if (excerpt == null) {
excerpt = new Post.Excerpt();
excerpt.setAutoGenerate(true);
spec.setExcerpt(excerpt);
}
if (excerpt.getAutoGenerate()) {
postService.getContent(spec.getReleaseSnapshot(), spec.getBaseSnapshot())
.blockOptional()
.ifPresent(content -> {
String contentRevised = content.getContent();
status.setExcerpt(getExcerpt(contentRevised));
});
} else {
status.setExcerpt(excerpt.getRaw());
}
Ref ref = Ref.of(post);
// handle contributors
String headSnapshot = post.getSpec().getHeadSnapshot();
List<String> contributors = client.list(Snapshot.class,
snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null)
.stream()
.peek(snapshot -> {
snapshot.getSpec().setContentPatch(StringUtils.EMPTY);
snapshot.getSpec().setRawPatch(StringUtils.EMPTY);
})
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames,
() -> new HashSet<String>());
})
.flatMap(Set::stream)
.distinct()
.sorted()
.toList();
status.setContributors(contributors);
// update in progress status
status.setInProgress(
!StringUtils.equals(headSnapshot, post.getSpec().getReleaseSnapshot()));
if (post.isPublished() && status.getLastModifyTime() == null) {
client.fetch(Snapshot.class, post.getSpec().getReleaseSnapshot())
.ifPresent(releasedSnapshot ->
status.setLastModifyTime(releasedSnapshot.getSpec().getLastModifyTime()));
}
if (!oldPost.equals(post)) {
client.update(post);
}
});
}
private void addFinalizerIfNecessary(Post oldPost) {
Set<String> finalizers = oldPost.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(Post.class, oldPost.getMetadata().getName())
.ifPresent(post -> {
Set<String> newFinalizers = post.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
post.getMetadata().setFinalizers(newFinalizers);
}
newFinalizers.add(FINALIZER_NAME);
client.update(post);
});
}
private void cleanUpResourcesAndRemoveFinalizer(String postName) {
client.fetch(Post.class, postName).ifPresent(post -> {
cleanUpResources(post);
if (post.getMetadata().getFinalizers() != null) {
post.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
client.update(post);
});
events.add(new PostUnpublishedEvent(this, post.getMetadata().getName()));
}
private void cleanUpResources(Post post) {
@ -377,7 +285,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
.forEach(client::delete);
// clean up comments
client.list(Comment.class, comment -> comment.getSpec().getSubjectRef().equals(ref),
client.list(Comment.class, comment -> ref.equals(comment.getSpec().getSubjectRef()),
null)
.forEach(client::delete);

View File

@ -2,15 +2,16 @@ package run.halo.app.event.post;
import org.springframework.context.ApplicationEvent;
public class PostRecycledEvent extends ApplicationEvent implements PostEvent {
public class PostUpdatedEvent extends ApplicationEvent implements PostEvent {
private final String postName;
public PostRecycledEvent(Object source, String postName) {
public PostUpdatedEvent(Object source, String postName) {
super(source);
this.postName = postName;
}
@Override
public String getName() {
return postName;
}

View File

@ -1,6 +1,7 @@
package run.halo.app.event.post;
import lombok.Data;
import org.springframework.lang.Nullable;
import run.halo.app.core.extension.content.Post;
@Data
@ -8,6 +9,7 @@ public class PostVisibleChangedEvent implements PostEvent {
private final String postName;
@Nullable
private final Post.VisibleEnum oldVisible;
private final Post.VisibleEnum newVisible;

View File

@ -7,9 +7,9 @@ import org.springframework.util.StringUtils;
*
* @author johnniang
*/
public final class ExtensionUtil {
public final class ExtensionStoreUtil {
private ExtensionUtil() {
private ExtensionStoreUtil() {
}
/**

View File

@ -2,6 +2,7 @@ package run.halo.app.extension;
import static org.openapi4j.core.validation.ValidationSeverity.ERROR;
import static org.springframework.util.StringUtils.arrayToCommaDelimitedString;
import static run.halo.app.extension.ExtensionStoreUtil.buildStoreName;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -65,7 +66,7 @@ public class JSONExtensionConverter implements ExtensionConverter {
}
var version = extension.getMetadata().getVersion();
var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName());
var storeName = buildStoreName(scheme, extension.getMetadata().getName());
var data = objectMapper.writeValueAsBytes(extensionJsonNode);
return new ExtensionStore(storeName, data, version);
} catch (IOException e) {

View File

@ -46,7 +46,7 @@ public class ReactiveExtensionClientImpl implements ReactiveExtensionClient {
public <E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator) {
var scheme = schemeManager.get(type);
var prefix = ExtensionUtil.buildStoreNamePrefix(scheme);
var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme);
return client.listByNamePrefix(prefix)
.map(extensionStore -> converter.convertFrom(type, extensionStore))
@ -75,14 +75,14 @@ public class ReactiveExtensionClientImpl implements ReactiveExtensionClient {
@Override
public <E extends Extension> Mono<E> fetch(Class<E> type, String name) {
var storeName = ExtensionUtil.buildStoreName(schemeManager.get(type), name);
var storeName = ExtensionStoreUtil.buildStoreName(schemeManager.get(type), name);
return client.fetchByName(storeName)
.map(extensionStore -> converter.convertFrom(type, extensionStore));
}
@Override
public Mono<Unstructured> fetch(GroupVersionKind gvk, String name) {
var storeName = ExtensionUtil.buildStoreName(schemeManager.get(gvk), name);
var storeName = ExtensionStoreUtil.buildStoreName(schemeManager.get(gvk), name);
return client.fetchByName(storeName)
.map(extensionStore -> converter.convertFrom(Unstructured.class, extensionStore));
}

View File

@ -3,6 +3,7 @@ package run.halo.app.search;
import org.springframework.stereotype.Service;
import reactor.core.Exceptions;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.content.Post;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.search.post.PostSearchService;
@ -23,28 +24,42 @@ public class IndicesServiceImpl implements IndicesService {
@Override
public Mono<Void> rebuildPostIndices() {
return extensionGetter.getEnabledExtension(PostSearchService.class)
.flatMap(searchService -> postFinder.listAll()
.filter(post -> Post.isPublished(post.getMetadata()))
.flatMap(listedPostVo -> {
PostVo postVo = PostVo.from(listedPostVo);
return postFinder.content(postVo.getMetadata().getName())
.map(content -> {
postVo.setContent(content);
return postVo;
})
.defaultIfEmpty(postVo);
})
.map(PostDocUtils::from)
.limitRate(100)
.buffer(100)
.doOnNext(postDocs -> {
try {
searchService.addDocuments(postDocs);
} catch (Exception e) {
throw Exceptions.propagate(e);
}
})
.then()
);
.flatMap(searchService -> Mono.fromRunnable(
() -> {
try {
// remove all docs before rebuilding
searchService.removeAllDocuments();
} catch (Exception e) {
throw Exceptions.propagate(e);
}
})
.then(rebuildPostIndices(searchService))
)
.subscribeOn(Schedulers.boundedElastic());
}
private Mono<Void> rebuildPostIndices(PostSearchService searchService) {
return postFinder.listAll()
.filter(post -> Post.isPublished(post.getMetadata()))
.flatMap(listedPostVo -> {
PostVo postVo = PostVo.from(listedPostVo);
return postFinder.content(postVo.getMetadata().getName())
.map(content -> {
postVo.setContent(content);
return postVo;
})
.defaultIfEmpty(postVo);
})
.map(PostDocUtils::from)
.limitRate(100)
.buffer(100)
.doOnNext(postDocs -> {
try {
searchService.addDocuments(postDocs);
} catch (Exception e) {
throw Exceptions.propagate(e);
}
})
.then();
}
}

View File

@ -126,6 +126,15 @@ public class LucenePostSearchService implements PostSearchService, DisposableBea
}
}
@Override
public void removeAllDocuments() throws Exception {
var writeConfig = new IndexWriterConfig(analyzer);
writeConfig.setOpenMode(APPEND);
try (var writer = new IndexWriter(postIndexDir, writeConfig)) {
writer.deleteAll();
}
}
@Override
public void destroy() throws Exception {
analyzer.close();
@ -145,11 +154,19 @@ public class LucenePostSearchService implements PostSearchService, DisposableBea
doc.add(new StringField("name", post.name(), YES));
doc.add(new StoredField("title", post.title()));
var content = Jsoup.clean(stripToEmpty(post.excerpt()) + stripToEmpty(post.content()),
Safelist.none());
var cleanExcerpt = Jsoup.clean(stripToEmpty(post.excerpt()), Safelist.none());
var cleanContent = Jsoup.clean(stripToEmpty(post.content()), Safelist.none());
var contentBuilder = new StringBuilder(cleanExcerpt);
if (!contentBuilder.isEmpty()) {
contentBuilder.append(' ');
}
contentBuilder.append(cleanContent);
var content = contentBuilder.toString();
doc.add(new StoredField("content", content));
doc.add(new TextField("searchable", post.title() + content, NO));
doc.add(new TextField("searchable", post.title() + " " + content, NO));
long publishTimestamp = post.publishTimestamp().toEpochMilli();
doc.add(new LongPoint("publishTimestamp", publishTimestamp));

View File

@ -13,8 +13,8 @@ import org.springframework.stereotype.Component;
import reactor.core.Exceptions;
import run.halo.app.event.post.PostEvent;
import run.halo.app.event.post.PostPublishedEvent;
import run.halo.app.event.post.PostRecycledEvent;
import run.halo.app.event.post.PostUnpublishedEvent;
import run.halo.app.event.post.PostUpdatedEvent;
import run.halo.app.event.post.PostVisibleChangedEvent;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
@ -52,11 +52,10 @@ public class PostEventReconciler implements Reconciler<PostEvent>, SmartLifecycl
@Override
public Result reconcile(PostEvent postEvent) {
if (postEvent instanceof PostPublishedEvent) {
if (postEvent instanceof PostPublishedEvent || postEvent instanceof PostUpdatedEvent) {
addPostDoc(postEvent.getName());
}
if (postEvent instanceof PostUnpublishedEvent
|| postEvent instanceof PostRecycledEvent) {
if (postEvent instanceof PostUnpublishedEvent) {
deletePostDoc(postEvent.getName());
}
if (postEvent instanceof PostVisibleChangedEvent visibleChangedEvent) {
@ -81,29 +80,13 @@ public class PostEventReconciler implements Reconciler<PostEvent>, SmartLifecycl
);
}
@EventListener(PostPublishedEvent.class)
public void handlePostPublished(PostPublishedEvent publishedEvent) {
postEventQueue.addImmediately(publishedEvent);
}
@EventListener(PostUnpublishedEvent.class)
public void handlePostUnpublished(PostUnpublishedEvent unpublishedEvent) {
postEventQueue.addImmediately(unpublishedEvent);
}
@EventListener(PostRecycledEvent.class)
public void handlePostRecycled(PostRecycledEvent recycledEvent) {
postEventQueue.addImmediately(recycledEvent);
}
@EventListener(PostVisibleChangedEvent.class)
public void handlePostVisibleChanged(PostVisibleChangedEvent event) {
@EventListener(PostEvent.class)
public void handlePostEvent(PostEvent event) {
postEventQueue.addImmediately(event);
}
void addPostDoc(String postName) {
postFinder.getByName(postName)
.filter(postVo -> PUBLIC.equals(postVo.getSpec().getVisible()))
.map(PostDocUtils::from)
.flatMap(postDoc -> extensionGetter.getEnabledExtension(PostSearchService.class)
.doOnNext(searchService -> {

View File

@ -19,6 +19,14 @@ import run.halo.app.theme.finders.vo.PostVo;
*/
public interface PostFinder {
/**
* Gets post detail by name.
* <p>
* We ensure the post is public, non-deleted and published.
*
* @param postName is post name
* @return post detail
*/
Mono<PostVo> getByName(String postName);
Mono<ContentVo> content(String postName);

View File

@ -77,7 +77,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(3)).update(captor.capture());
verify(client, times(1)).update(captor.capture());
verify(postPermalinkPolicy, times(1)).permalink(any());
@ -118,7 +118,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(4)).update(captor.capture());
verify(client, times(1)).update(captor.capture());
Post value = captor.getValue();
assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world");
}
@ -154,7 +154,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(4)).update(captor.capture());
verify(client, times(1)).update(captor.capture());
Post value = captor.getValue();
assertThat(value.getStatus().getLastModifyTime()).isEqualTo(lastModifyTime);
verify(eventPublisher).publishEvent(any(PostPublishedEvent.class));
@ -183,7 +183,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(3)).update(captor.capture());
verify(client, times(1)).update(captor.capture());
Post value = captor.getValue();
assertThat(value.getStatus().getLastModifyTime()).isNull();
}

View File

@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ExtensionUtilTest {
class ExtensionStoreUtilTest {
Scheme scheme;
@ -28,19 +28,19 @@ class ExtensionUtilTest {
@Test
void buildStoreNamePrefix() {
var prefix = ExtensionUtil.buildStoreNamePrefix(scheme);
var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme);
assertEquals("/registry/fake.halo.run/fakes", prefix);
prefix = ExtensionUtil.buildStoreNamePrefix(grouplessScheme);
prefix = ExtensionStoreUtil.buildStoreNamePrefix(grouplessScheme);
assertEquals("/registry/fakes", prefix);
}
@Test
void buildStoreName() {
var storeName = ExtensionUtil.buildStoreName(scheme, "fake-name");
var storeName = ExtensionStoreUtil.buildStoreName(scheme, "fake-name");
assertEquals("/registry/fake.halo.run/fakes/fake-name", storeName);
storeName = ExtensionUtil.buildStoreName(grouplessScheme, "fake-name");
storeName = ExtensionStoreUtil.buildStoreName(grouplessScheme, "fake-name");
assertEquals("/registry/fakes/fake-name", storeName);
}