Benki: Cache HTML renderings of posts in the database.

Change-Id: I3367ceb8769d354f64165d23ec7ed1f2155c4c49
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
index 5c1342a..a659049 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
@@ -38,7 +38,7 @@
 
   @Transient
   @CheckForNull
-  public String getDescriptionHtml() {
+  protected String computeDescriptionHtml() {
     if (description == null) {
       return null;
     }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
index 059335c..f81ed04 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
@@ -109,6 +109,7 @@
     bookmark.title = title;
     bookmark.tags = Set.of();
     bookmark.description = description;
+    bookmark.cachedDescriptionHtml = null;
     bookmark.owner = user;
 
     assignPostTargets(visibility, user, bookmark);
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
index ffafb96..5cec6aa 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
@@ -31,10 +31,7 @@
   @JsonbTransient
   @CheckForNull
   public String getContentHtml() {
-    if (content == null) {
-      return null;
-    }
-    return new MarkdownConverter().htmlify(content);
+    return getDescriptionHtml();
   }
 
   @CheckForNull
@@ -52,8 +49,11 @@
   @CheckForNull
   @Override
   @JsonbTransient
-  public String getDescriptionHtml() {
-    return getContentHtml();
+  protected String computeDescriptionHtml() {
+    if (content == null) {
+      return null;
+    }
+    return new MarkdownConverter().htmlify(content);
   }
 
   @Override
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
index cbdbe76..156b638 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
@@ -82,6 +82,7 @@
     }
 
     message.content = text;
+    message.cachedDescriptionHtml = null;
     message.format = "markdown";
 
     assignPostTargets(visibility, user, message);
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index bbfafa2..aa15fa2 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -47,6 +47,8 @@
 
   private static final Logger log = Logger.getLogger(Post.class);
 
+  private static final int DESCRIPTION_CACHE_VERSION = 1;
+
   @Id
   @SequenceGenerator(
       allocationSize = 1,
@@ -61,6 +63,14 @@
   @CheckForNull
   public OffsetDateTime date;
 
+  @Column(name = "cached_description_version", nullable = true)
+  @CheckForNull
+  public Integer cachedDescriptionVersion;
+
+  @Column(name = "cached_description_html", nullable = true)
+  @CheckForNull
+  public String cachedDescriptionHtml;
+
   @ManyToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "owner", referencedColumnName = "id")
   @CheckForNull
@@ -93,7 +103,21 @@
   public abstract String getTitle();
 
   @CheckForNull
-  public abstract String getDescriptionHtml();
+  public final String getDescriptionHtml() {
+    if (cachedDescriptionHtml != null &&
+        cachedDescriptionVersion != null &&
+        cachedDescriptionVersion >= DESCRIPTION_CACHE_VERSION){
+      return cachedDescriptionHtml;
+    } else {
+      @CheckForNull var descriptionHtml = computeDescriptionHtml();
+      cachedDescriptionHtml = descriptionHtml;
+      cachedDescriptionVersion = DESCRIPTION_CACHE_VERSION;
+      return descriptionHtml;
+    }
+  }
+
+  @CheckForNull
+  protected abstract String computeDescriptionHtml();
 
   @CheckForNull
   public abstract String getUri();
@@ -190,6 +214,10 @@
       this.posts = resultList;
     }
 
+    public void cacheDescriptions() {
+      days().forEach(Day::cacheDescriptions);
+    }
+
     public class Day {
       public final @CheckForNull LocalDate date;
       public final List<T> posts;
@@ -198,6 +226,12 @@
         this.date = date;
         this.posts = posts;
       }
+
+      public void cacheDescriptions() {
+        for (var post : posts) {
+          post.getDescriptionHtml();
+        }
+      }
     }
 
     public List<Day> days() {
@@ -211,9 +245,9 @@
     }
   }
 
-  public static List<Post> findViewable(
+  public static PostPage<Post> findViewable(
       PostFilter postFilter, Session session, @CheckForNull User viewer, @CheckForNull User owner) {
-    return findViewable(postFilter, session, viewer, owner, null, null).posts;
+    return findViewable(postFilter, session, viewer, owner, null, null);
   }
 
   public static PostPage<Post> findViewable(
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
index 580f8bc..f58140e 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
@@ -113,6 +113,7 @@
 
   @GET
   @Produces(TEXT_HTML)
+  @Transactional
   public TemplateInstance getIndex(
       @QueryParam("i") @CheckForNull Integer cursor,
       @QueryParam("n") @CheckForNull Integer maxResults) {
@@ -123,6 +124,8 @@
     var session = entityManager.unwrap(Session.class);
     var q = Post.findViewable(postFilter, session, reader, null, cursor, maxResults);
 
+    q.cacheDescriptions();
+
     var feedUri = uri.getPath() + "/feed";
     if (reader != null) {
       var pageKey = ensurePageKey(reader, feedUri);
@@ -145,6 +148,7 @@
   @GET
   @Path("~{ownerName}")
   @Produces(TEXT_HTML)
+  @Transactional
   public TemplateInstance getUserIndex(
       @PathParam("ownerName") String ownerName,
       @QueryParam("i") @CheckForNull Integer cursor,
@@ -157,6 +161,8 @@
     var session = entityManager.unwrap(Session.class);
     var q = Post.findViewable(postFilter, session, reader, owner, cursor, maxResults);
 
+    q.cacheDescriptions();
+
     var feedUri = uri.getPath() + "/feed";
     if (reader != null) {
       var pageKey = ensurePageKey(reader, feedUri);
@@ -219,6 +225,7 @@
   @GET
   @Path("feed")
   @Produces(APPLICATION_ATOM_XML)
+  @Transactional
   public String getFeed(@QueryParam("page-key") @CheckForNull String pageKeyBase36)
       throws FeedException {
     @CheckForNull var pageKey = pageKeyBase36 == null ? null : new BigInteger(pageKeyBase36, 36);
@@ -228,6 +235,7 @@
   @GET
   @Path("~{ownerName}/feed")
   @Produces(APPLICATION_ATOM_XML)
+  @Transactional
   public String getUserFeed(
       @QueryParam("page-key") @CheckForNull String pageKeyBase36,
       @PathParam("ownerName") String ownerName)
@@ -257,7 +265,10 @@
   private String makeFeed(
       @CheckForNull User reader, @Nullable User owner, @Nullable String ownerName)
       throws FeedException {
-    var posts = Post.findViewable(postFilter, entityManager.unwrap(Session.class), reader, owner);
+    var q = Post.findViewable(postFilter, entityManager.unwrap(Session.class), reader, owner);
+    q.cacheDescriptions();
+    var posts = q.posts;
+
     var feed = new Feed("atom_1.0");
 
     var feedSubId = owner == null ? "" : String.format("/%d", owner.id);