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 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 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) { static boolean isDeleted(ExtensionOperator extension) {
return extension.getMetadata() != null return ExtensionUtil.isDeleted(extension);
&& extension.getMetadata().getDeletionTimestamp() != null;
} }
} }

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 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.PostRequest;
import run.halo.app.content.PostService; import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post; 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.ListResult;
import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
@ -262,9 +260,6 @@ public class PostEndpoint implements CustomEndpoint {
.flatMap(client::update)) .flatMap(client::update))
.retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException)) .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)); .flatMap(post -> ServerResponse.ok().bodyValue(post));
} }
@ -278,9 +273,6 @@ public class PostEndpoint implements CustomEndpoint {
.flatMap(client::update)) .flatMap(client::update))
.retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException)) .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)); .flatMap(post -> ServerResponse.ok().bodyValue(post));
} }

View File

@ -1,39 +1,45 @@
package run.halo.app.core.extension.reconciler; 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.time.Instant;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component; 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.PostService;
import run.halo.app.content.permalinks.PostPermalinkPolicy; import run.halo.app.content.permalinks.PostPermalinkPolicy;
import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post; 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.core.extension.content.Snapshot;
import run.halo.app.event.post.PostPublishedEvent; import run.halo.app.event.post.PostPublishedEvent;
import run.halo.app.event.post.PostUnpublishedEvent; 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.event.post.PostVisibleChangedEvent;
import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionOperator; import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.Ref; import run.halo.app.extension.Ref;
import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.Condition; import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionList;
import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.HaloUtils; 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.CounterService;
import run.halo.app.metrics.MeterUtils; import run.halo.app.metrics.MeterUtils;
@ -62,20 +68,134 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
@Override @Override
public Result reconcile(Request request) { public Result reconcile(Request request) {
var events = new HashSet<ApplicationEvent>();
client.fetch(Post.class, request.name()) client.fetch(Post.class, request.name())
.ifPresent(post -> { .ifPresent(post -> {
if (ExtensionOperator.isDeleted(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; return;
} }
addFinalizerIfNecessary(post); addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
// reconcile spec first var labels = post.getMetadata().getLabels();
reconcileSpec(request.name()); if (labels == null) {
reconcileMetadata(request.name()); labels = new HashMap<>();
reconcileStatus(request.name()); 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 @Override
@ -83,290 +203,78 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
return builder return builder
.extension(new Post()) .extension(new Post())
// TODO Make it configurable // TODO Make it configurable
.workerCount(10) .workerCount(1)
.build(); .build();
} }
private void reconcileSpec(String name) { private void publishPost(Post post, Set<ApplicationEvent> events) {
client.fetch(Post.class, name).ifPresent(post -> { var expectReleaseSnapshot = post.getSpec().getReleaseSnapshot();
// un-publish post if necessary if (StringUtils.isBlank(expectReleaseSnapshot)) {
if (post.isPublished() // Do nothing if release snapshot is not set
&& Objects.equals(false, post.getSpec().getPublish())) { return;
boolean success = unPublishReconcile(name); }
if (success) { var annotations = post.getMetadata().getAnnotations();
eventPublisher.publishEvent(new PostUnpublishedEvent(this, name)); var lastReleaseSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO);
} if (post.isPublished()
return; && Objects.equals(expectReleaseSnapshot, lastReleaseSnapshot)) {
} // If the release snapshot is not change
return;
try { }
publishPost(name); var status = post.getStatus();
} catch (Throwable e) { // validate the release snapshot
publishFailed(name, e); var snapshot = client.fetch(Snapshot.class, expectReleaseSnapshot);
throw e; if (snapshot.isEmpty()) {
}
});
}
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();
Condition condition = Condition.builder() Condition condition = Condition.builder()
.type(phase.name()) .type(PostPhase.FAILED.name())
.reason("PublishFailed") .reason("SnapshotNotFound")
.message(error.getMessage()) .message(
String.format("Snapshot [%s] not found for publish", expectReleaseSnapshot))
.status(ConditionStatus.FALSE) .status(ConditionStatus.FALSE)
.lastTransitionTime(Instant.now()) .lastTransitionTime(Instant.now())
.build(); .build();
conditions.addAndEvictFIFO(condition); status.getConditionsOrDefault().addAndEvictFIFO(condition);
status.setPhase(PostPhase.FAILED.name());
post.setStatus(status); return;
}
if (!oldPost.equals(post)) { annotations.put(Post.LAST_RELEASED_SNAPSHOT_ANNO, expectReleaseSnapshot);
client.update(post); status.setPhase(PostPhase.PUBLISHED.toString());
} var condition = Condition.builder()
}); .type(PostPhase.PUBLISHED.name())
} .reason("Published")
.message("Post published successfully.")
private void reconcileMetadata(String name) { .lastTransitionTime(Instant.now())
client.fetch(Post.class, name).ifPresent(post -> { .status(ConditionStatus.TRUE)
final Post oldPost = JsonUtils.deepCopy(post); .build();
Post.PostSpec spec = post.getSpec(); status.getConditionsOrDefault().addAndEvictFIFO(condition);
// 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) {
var labels = post.getMetadata().getLabels(); 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; return;
} }
var name = post.getMetadata().getName(); var labels = post.getMetadata().getLabels();
var oldVisibleStr = labels.get(Post.VISIBLE_LABEL); labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString());
if (oldVisibleStr != null) { var status = post.getStatus();
var oldVisible = Post.VisibleEnum.valueOf(oldVisibleStr);
var expectVisible = post.getSpec().getVisible();
if (!Objects.equals(oldVisible, expectVisible)) {
eventPublisher.publishEvent(
new PostVisibleChangedEvent(name, oldVisible, expectVisible));
}
}
}
private void reconcileStatus(String name) { var condition = new Condition();
client.fetch(Post.class, name).ifPresent(post -> { condition.setType("CancelledPublish");
final Post oldPost = JsonUtils.deepCopy(post); condition.setStatus(ConditionStatus.TRUE);
condition.setReason(condition.getType());
condition.setMessage("CancelledPublish");
condition.setLastTransitionTime(Instant.now());
status.getConditionsOrDefault().addAndEvictFIFO(condition);
post.getStatusOrDefault() status.setPhase(PostPhase.DRAFT.toString());
.setPermalink(postPermalinkPolicy.permalink(post));
Post.PostStatus status = post.getStatusOrDefault(); events.add(new PostUnpublishedEvent(this, post.getMetadata().getName()));
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);
});
} }
private void cleanUpResources(Post post) { private void cleanUpResources(Post post) {
@ -377,7 +285,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
.forEach(client::delete); .forEach(client::delete);
// clean up comments // clean up comments
client.list(Comment.class, comment -> comment.getSpec().getSubjectRef().equals(ref), client.list(Comment.class, comment -> ref.equals(comment.getSpec().getSubjectRef()),
null) null)
.forEach(client::delete); .forEach(client::delete);

View File

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

View File

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

View File

@ -7,9 +7,9 @@ import org.springframework.util.StringUtils;
* *
* @author johnniang * @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.openapi4j.core.validation.ValidationSeverity.ERROR;
import static org.springframework.util.StringUtils.arrayToCommaDelimitedString; 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -65,7 +66,7 @@ public class JSONExtensionConverter implements ExtensionConverter {
} }
var version = extension.getMetadata().getVersion(); 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); var data = objectMapper.writeValueAsBytes(extensionJsonNode);
return new ExtensionStore(storeName, data, version); return new ExtensionStore(storeName, data, version);
} catch (IOException e) { } 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, public <E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator) { Comparator<E> comparator) {
var scheme = schemeManager.get(type); var scheme = schemeManager.get(type);
var prefix = ExtensionUtil.buildStoreNamePrefix(scheme); var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme);
return client.listByNamePrefix(prefix) return client.listByNamePrefix(prefix)
.map(extensionStore -> converter.convertFrom(type, extensionStore)) .map(extensionStore -> converter.convertFrom(type, extensionStore))
@ -75,14 +75,14 @@ public class ReactiveExtensionClientImpl implements ReactiveExtensionClient {
@Override @Override
public <E extends Extension> Mono<E> fetch(Class<E> type, String name) { 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) return client.fetchByName(storeName)
.map(extensionStore -> converter.convertFrom(type, extensionStore)); .map(extensionStore -> converter.convertFrom(type, extensionStore));
} }
@Override @Override
public Mono<Unstructured> fetch(GroupVersionKind gvk, String name) { 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) return client.fetchByName(storeName)
.map(extensionStore -> converter.convertFrom(Unstructured.class, extensionStore)); .map(extensionStore -> converter.convertFrom(Unstructured.class, extensionStore));
} }

View File

@ -3,6 +3,7 @@ package run.halo.app.search;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.Exceptions; import reactor.core.Exceptions;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Post;
import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.search.post.PostSearchService; import run.halo.app.search.post.PostSearchService;
@ -23,28 +24,42 @@ public class IndicesServiceImpl implements IndicesService {
@Override @Override
public Mono<Void> rebuildPostIndices() { public Mono<Void> rebuildPostIndices() {
return extensionGetter.getEnabledExtension(PostSearchService.class) return extensionGetter.getEnabledExtension(PostSearchService.class)
.flatMap(searchService -> postFinder.listAll() .flatMap(searchService -> Mono.fromRunnable(
.filter(post -> Post.isPublished(post.getMetadata())) () -> {
.flatMap(listedPostVo -> { try {
PostVo postVo = PostVo.from(listedPostVo); // remove all docs before rebuilding
return postFinder.content(postVo.getMetadata().getName()) searchService.removeAllDocuments();
.map(content -> { } catch (Exception e) {
postVo.setContent(content); throw Exceptions.propagate(e);
return postVo; }
}) })
.defaultIfEmpty(postVo); .then(rebuildPostIndices(searchService))
}) )
.map(PostDocUtils::from) .subscribeOn(Schedulers.boundedElastic());
.limitRate(100) }
.buffer(100)
.doOnNext(postDocs -> { private Mono<Void> rebuildPostIndices(PostSearchService searchService) {
try { return postFinder.listAll()
searchService.addDocuments(postDocs); .filter(post -> Post.isPublished(post.getMetadata()))
} catch (Exception e) { .flatMap(listedPostVo -> {
throw Exceptions.propagate(e); PostVo postVo = PostVo.from(listedPostVo);
} return postFinder.content(postVo.getMetadata().getName())
}) .map(content -> {
.then() 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 @Override
public void destroy() throws Exception { public void destroy() throws Exception {
analyzer.close(); analyzer.close();
@ -145,11 +154,19 @@ public class LucenePostSearchService implements PostSearchService, DisposableBea
doc.add(new StringField("name", post.name(), YES)); doc.add(new StringField("name", post.name(), YES));
doc.add(new StoredField("title", post.title())); doc.add(new StoredField("title", post.title()));
var content = Jsoup.clean(stripToEmpty(post.excerpt()) + stripToEmpty(post.content()), var cleanExcerpt = Jsoup.clean(stripToEmpty(post.excerpt()), Safelist.none());
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 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(); long publishTimestamp = post.publishTimestamp().toEpochMilli();
doc.add(new LongPoint("publishTimestamp", publishTimestamp)); doc.add(new LongPoint("publishTimestamp", publishTimestamp));

View File

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

View File

@ -19,6 +19,14 @@ import run.halo.app.theme.finders.vo.PostVo;
*/ */
public interface PostFinder { 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<PostVo> getByName(String postName);
Mono<ContentVo> content(String postName); Mono<ContentVo> content(String postName);

View File

@ -77,7 +77,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class); ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name)); 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()); verify(postPermalinkPolicy, times(1)).permalink(any());
@ -118,7 +118,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class); ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name)); postReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(4)).update(captor.capture()); verify(client, times(1)).update(captor.capture());
Post value = captor.getValue(); Post value = captor.getValue();
assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world"); assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world");
} }
@ -154,7 +154,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class); ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name)); postReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(4)).update(captor.capture()); verify(client, times(1)).update(captor.capture());
Post value = captor.getValue(); Post value = captor.getValue();
assertThat(value.getStatus().getLastModifyTime()).isEqualTo(lastModifyTime); assertThat(value.getStatus().getLastModifyTime()).isEqualTo(lastModifyTime);
verify(eventPublisher).publishEvent(any(PostPublishedEvent.class)); verify(eventPublisher).publishEvent(any(PostPublishedEvent.class));
@ -183,7 +183,7 @@ class PostReconcilerTest {
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class); ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name)); postReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(3)).update(captor.capture()); verify(client, times(1)).update(captor.capture());
Post value = captor.getValue(); Post value = captor.getValue();
assertThat(value.getStatus().getLastModifyTime()).isNull(); 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class ExtensionUtilTest { class ExtensionStoreUtilTest {
Scheme scheme; Scheme scheme;
@ -28,19 +28,19 @@ class ExtensionUtilTest {
@Test @Test
void buildStoreNamePrefix() { void buildStoreNamePrefix() {
var prefix = ExtensionUtil.buildStoreNamePrefix(scheme); var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme);
assertEquals("/registry/fake.halo.run/fakes", prefix); assertEquals("/registry/fake.halo.run/fakes", prefix);
prefix = ExtensionUtil.buildStoreNamePrefix(grouplessScheme); prefix = ExtensionStoreUtil.buildStoreNamePrefix(grouplessScheme);
assertEquals("/registry/fakes", prefix); assertEquals("/registry/fakes", prefix);
} }
@Test @Test
void buildStoreName() { 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); 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); assertEquals("/registry/fakes/fake-name", storeName);
} }