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,259 +68,114 @@ 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));
return; unPublishPost(post, events);
} cleanUpResources(post);
addFinalizerIfNecessary(post); // update post to be able to be collected by gc collector.
// reconcile spec first
reconcileSpec(request.name());
reconcileMetadata(request.name());
reconcileStatus(request.name());
});
return new Result(false, null);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Post())
// TODO Make it configurable
.workerCount(10)
.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); client.update(post);
// fire event after updating post
events.forEach(eventPublisher::publishEvent);
return; return;
} }
// do publish addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
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); var labels = post.getMetadata().getLabels();
if (post.getSpec().getPublishTime() == null) { if (labels == null) {
post.getSpec().setPublishTime(Instant.now()); labels = new HashMap<>();
post.getMetadata().setLabels(labels);
} }
// populate lastModifyTime var annotations = post.getMetadata().getAnnotations();
status.setLastModifyTime(releasedSnapshotOpt.get().getSpec().getLastModifyTime()); if (annotations == null) {
annotations = new HashMap<>();
client.update(post); post.getMetadata().setAnnotations(annotations);
eventPublisher.publishEvent(new PostPublishedEvent(this, name));
});
} }
private boolean unPublishReconcile(String name) { var status = post.getStatus();
return client.fetch(Post.class, name) if (status == null) {
.map(post -> { status = new Post.PostStatus();
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()
.type(phase.name())
.reason("PublishFailed")
.message(error.getMessage())
.status(ConditionStatus.FALSE)
.lastTransitionTime(Instant.now())
.build();
conditions.addAndEvictFIFO(condition);
post.setStatus(status); post.setStatus(status);
if (!oldPost.equals(post)) {
client.update(post);
}
});
} }
private void reconcileMetadata(String name) { // calculate the sha256sum
client.fetch(Post.class, name).ifPresent(post -> { var configSha256sum = Hashing.sha256().hashString(post.getSpec().toString(), UTF_8)
final Post oldPost = JsonUtils.deepCopy(post); .toString();
Post.PostSpec spec = post.getSpec();
// handle logic delete var oldConfigChecksum = annotations.get(Constant.CHECKSUM_CONFIG_ANNO);
Map<String, String> labels = MetadataUtil.nullSafeLabels(post); if (!Objects.equals(oldConfigChecksum, configSha256sum)) {
if (Objects.equals(spec.getDeleted(), true)) { // if the checksum doesn't match
labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString()); 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 { } else {
labels.put(Post.DELETED_LABEL, Boolean.FALSE.toString()); publishPost(post, events);
} }
fireVisibleChangedEventIfChanged(post); labels.put(Post.DELETED_LABEL, expectDelete.toString());
labels.put(Post.VISIBLE_LABEL,
Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name());
labels.put(Post.OWNER_LABEL, spec.getOwner()); var expectVisible = defaultIfNull(post.getSpec().getVisible(), VisibleEnum.PUBLIC);
Instant publishTime = post.getSpec().getPublishTime(); 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) { if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime)); labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime)); labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(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); var permalinkPattern = postPermalinkPolicy.pattern();
String newPattern = postPermalinkPolicy.pattern(); annotations.put(Constant.PERMALINK_PATTERN_ANNO, permalinkPattern);
annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern);
if (!oldPost.equals(post)) { status.setPermalink(postPermalinkPolicy.permalink(post));
client.update(post);
}
});
}
private void fireVisibleChangedEventIfChanged(Post post) {
var labels = post.getMetadata().getLabels();
if (labels == null) {
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));
}
}
}
private void reconcileStatus(String name) {
client.fetch(Post.class, name).ifPresent(post -> {
final Post oldPost = JsonUtils.deepCopy(post);
post.getStatusOrDefault()
.setPermalink(postPermalinkPolicy.permalink(post));
Post.PostStatus status = post.getStatusOrDefault();
if (status.getPhase() == null) { if (status.getPhase() == null) {
status.setPhase(Post.PostPhase.DRAFT.name()); status.setPhase(PostPhase.DRAFT.toString());
} }
Post.PostSpec spec = post.getSpec();
// handle excerpt var excerpt = post.getSpec().getExcerpt();
Post.Excerpt excerpt = spec.getExcerpt();
if (excerpt == null) { if (excerpt == null) {
excerpt = new Post.Excerpt(); excerpt = new Post.Excerpt();
excerpt.setAutoGenerate(true);
spec.setExcerpt(excerpt);
} }
if (excerpt.getAutoGenerate()) { var isAutoGenerate = defaultIfNull(excerpt.getAutoGenerate(), true);
postService.getContent(spec.getReleaseSnapshot(), spec.getBaseSnapshot()) if (isAutoGenerate) {
.blockOptional() Optional<ContentWrapper> contentWrapper =
.ifPresent(content -> { postService.getContent(post.getSpec().getReleaseSnapshot(),
String contentRevised = content.getContent(); post.getSpec().getBaseSnapshot())
.blockOptional();
if (contentWrapper.isPresent()) {
String contentRevised = contentWrapper.get().getContent();
status.setExcerpt(getExcerpt(contentRevised)); status.setExcerpt(getExcerpt(contentRevised));
}); }
} else { } else {
status.setExcerpt(excerpt.getRaw()); status.setExcerpt(excerpt.getRaw());
} }
Ref ref = Ref.of(post);
var ref = Ref.of(post);
// handle contributors // handle contributors
String headSnapshot = post.getSpec().getHeadSnapshot(); var headSnapshot = post.getSpec().getHeadSnapshot();
List<String> contributors = client.list(Snapshot.class, var contributors = client.list(Snapshot.class,
snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null) snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null)
.stream() .stream()
.peek(snapshot -> {
snapshot.getSpec().setContentPatch(StringUtils.EMPTY);
snapshot.getSpec().setRawPatch(StringUtils.EMPTY);
})
.map(snapshot -> { .map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors(); Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames, return Objects.requireNonNullElseGet(usernames,
@ -330,43 +191,90 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
status.setInProgress( status.setInProgress(
!StringUtils.equals(headSnapshot, post.getSpec().getReleaseSnapshot())); !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); client.update(post);
} // fire event after updating post
events.forEach(eventPublisher::publishEvent);
}); });
return Result.doNotRetry();
} }
private void addFinalizerIfNecessary(Post oldPost) { @Override
Set<String> finalizers = oldPost.getMetadata().getFinalizers(); public Controller setupWith(ControllerBuilder builder) {
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { return builder
.extension(new Post())
// TODO Make it configurable
.workerCount(1)
.build();
}
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; return;
} }
client.fetch(Post.class, oldPost.getMetadata().getName()) var annotations = post.getMetadata().getAnnotations();
.ifPresent(post -> { var lastReleaseSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO);
Set<String> newFinalizers = post.getMetadata().getFinalizers(); if (post.isPublished()
if (newFinalizers == null) { && Objects.equals(expectReleaseSnapshot, lastReleaseSnapshot)) {
newFinalizers = new HashSet<>(); // If the release snapshot is not change
post.getMetadata().setFinalizers(newFinalizers); return;
} }
newFinalizers.add(FINALIZER_NAME); var status = post.getStatus();
client.update(post); // validate the release snapshot
}); var snapshot = client.fetch(Snapshot.class, expectReleaseSnapshot);
if (snapshot.isEmpty()) {
Condition condition = Condition.builder()
.type(PostPhase.FAILED.name())
.reason("SnapshotNotFound")
.message(
String.format("Snapshot [%s] not found for publish", expectReleaseSnapshot))
.status(ConditionStatus.FALSE)
.lastTransitionTime(Instant.now())
.build();
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();
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()));
} }
private void cleanUpResourcesAndRemoveFinalizer(String postName) { void unPublishPost(Post post, Set<ApplicationEvent> events) {
client.fetch(Post.class, postName).ifPresent(post -> { if (!post.isPublished()) {
cleanUpResources(post); return;
if (post.getMetadata().getFinalizers() != null) {
post.getMetadata().getFinalizers().remove(FINALIZER_NAME);
} }
client.update(post); var labels = post.getMetadata().getLabels();
}); labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString());
var status = post.getStatus();
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);
status.setPhase(PostPhase.DRAFT.toString());
events.add(new PostUnpublishedEvent(this, post.getMetadata().getName()));
} }
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,7 +24,22 @@ 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(
() -> {
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())) .filter(post -> Post.isPublished(post.getMetadata()))
.flatMap(listedPostVo -> { .flatMap(listedPostVo -> {
PostVo postVo = PostVo.from(listedPostVo); PostVo postVo = PostVo.from(listedPostVo);
@ -44,7 +60,6 @@ public class IndicesServiceImpl implements IndicesService {
throw Exceptions.propagate(e); throw Exceptions.propagate(e);
} }
}) })
.then() .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);
} }