fix: issue #3 slow rendering
This commit is contained in:
292
test/bds/rendering/render_context_test.exs
Normal file
292
test/bds/rendering/render_context_test.exs
Normal file
@@ -0,0 +1,292 @@
|
||||
defmodule BDS.Rendering.RenderContextTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.Generation
|
||||
alias BDS.Rendering
|
||||
alias BDS.Rendering.RenderContext
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-render-context-#{System.unique_integer([:positive])}")
|
||||
|
||||
File.mkdir_p!(temp_dir)
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
{:ok, project} = BDS.Projects.create_project(%{name: "RenderContext", data_path: temp_dir})
|
||||
|
||||
{:ok, _metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{
|
||||
main_language: "en",
|
||||
blog_languages: ["en", "de"]
|
||||
})
|
||||
|
||||
%{project: project, temp_dir: temp_dir}
|
||||
end
|
||||
|
||||
defp create_published_post(project, attrs) do
|
||||
{:ok, post} =
|
||||
BDS.Posts.create_post(
|
||||
Map.merge(
|
||||
%{project_id: project.id, language: "en", content: "Body"},
|
||||
attrs
|
||||
)
|
||||
)
|
||||
|
||||
{:ok, published} = BDS.Posts.publish_post(post.id)
|
||||
published
|
||||
end
|
||||
|
||||
test "build/1 preloads canonical paths, menu, tag colors, and link maps", %{project: project} do
|
||||
target = create_published_post(project, %{title: "Ctx Target", tags: ["alpha"]})
|
||||
|
||||
_source =
|
||||
create_published_post(project, %{
|
||||
title: "Ctx Source",
|
||||
content: "See [Target](/#{Enum.join(post_date_parts(target), "/")}/#{target.slug}/)"
|
||||
})
|
||||
|
||||
ctx = RenderContext.build(project.id)
|
||||
|
||||
assert ctx.project.id == project.id
|
||||
assert ctx.main_language == "en"
|
||||
assert Map.has_key?(ctx.canonical_post_paths, target.slug)
|
||||
assert is_map(ctx.canonical_media_paths)
|
||||
assert is_map(ctx.tag_color_by_name)
|
||||
assert is_list(ctx.menu_items)
|
||||
|
||||
incoming = Map.get(ctx.incoming_links_by_post, target.id, [])
|
||||
assert Enum.any?(incoming, fn link -> link.title == "Ctx Source" end)
|
||||
end
|
||||
|
||||
test "memoize/3 executes the computation only once per key", %{project: project} do
|
||||
ctx = RenderContext.build(project.id)
|
||||
{:ok, counter} = Agent.start_link(fn -> 0 end)
|
||||
|
||||
compute = fn ->
|
||||
Agent.update(counter, &(&1 + 1))
|
||||
:computed
|
||||
end
|
||||
|
||||
assert RenderContext.memoize(ctx, {:test, "key"}, compute) == :computed
|
||||
assert RenderContext.memoize(ctx, {:test, "key"}, compute) == :computed
|
||||
assert Agent.get(counter, & &1) == 1
|
||||
end
|
||||
|
||||
test "render_post_page with a context matches the project_id render", %{project: project} do
|
||||
{:ok, template} =
|
||||
BDS.Templates.create_template(%{
|
||||
project_id: project.id,
|
||||
title: "Ctx Parity",
|
||||
kind: :post,
|
||||
content:
|
||||
"{{ page_title }}|{{ post.author }}|{{ post.tags.size }}|{% for lang in blog_languages %}[{{ lang.code }}]{% endfor %}|{{ content }}"
|
||||
})
|
||||
|
||||
{:ok, published_template} = BDS.Templates.publish_template(template.id)
|
||||
|
||||
post =
|
||||
create_published_post(project, %{
|
||||
title: "Parity Post",
|
||||
author: "Writer",
|
||||
tags: ["alpha", "beta"],
|
||||
content: "Hello **world**",
|
||||
template_slug: published_template.slug
|
||||
})
|
||||
|
||||
assigns = %{
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
content: "Hello **world**",
|
||||
slug: post.slug,
|
||||
language: "en",
|
||||
excerpt: post.excerpt,
|
||||
template_slug: post.template_slug
|
||||
}
|
||||
|
||||
assert {:ok, from_project_id} =
|
||||
Rendering.render_post_page(project.id, published_template.slug, assigns)
|
||||
|
||||
ctx = RenderContext.build(project.id)
|
||||
|
||||
assert {:ok, from_ctx} =
|
||||
Rendering.render_post_page(ctx, published_template.slug, assigns)
|
||||
|
||||
assert from_ctx == from_project_id
|
||||
end
|
||||
|
||||
test "render_list_page with a context matches the project_id render", %{project: project} do
|
||||
post = create_published_post(project, %{title: "List Parity", content: "List body"})
|
||||
|
||||
assigns = %{
|
||||
language: "en",
|
||||
page_title: "Home",
|
||||
posts: [
|
||||
%{
|
||||
id: post.id,
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
href: "/#{post.slug}/",
|
||||
excerpt: post.excerpt,
|
||||
content: "List body"
|
||||
}
|
||||
],
|
||||
archive_context: %{kind: "core"},
|
||||
pagination: %{
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
total_items: 1,
|
||||
items_per_page: 10,
|
||||
has_prev_page: false,
|
||||
prev_page_href: "",
|
||||
has_next_page: false,
|
||||
next_page_href: ""
|
||||
}
|
||||
}
|
||||
|
||||
assert {:ok, from_project_id} = Rendering.render_list_page(project.id, assigns)
|
||||
|
||||
ctx = RenderContext.build(project.id)
|
||||
assert {:ok, from_ctx} = Rendering.render_list_page(ctx, assigns)
|
||||
|
||||
assert from_ctx == from_project_id
|
||||
end
|
||||
|
||||
test "render_template caches the parsed template AST in the context", %{project: project} do
|
||||
ctx = RenderContext.build(project.id)
|
||||
source = "Hello {{ name }}"
|
||||
|
||||
assert {:ok, "Hello A"} =
|
||||
BDS.Rendering.TemplateSelection.render_template(ctx, source, %{"name" => "A"})
|
||||
|
||||
assert {:ok, "Hello B"} =
|
||||
BDS.Rendering.TemplateSelection.render_template(ctx, source, %{"name" => "B"})
|
||||
|
||||
ast_entries =
|
||||
:ets.tab2list(ctx.cache)
|
||||
|> Enum.filter(fn {key, _value} -> match?({:template_ast, _hash}, key) end)
|
||||
|
||||
assert length(ast_entries) == 1
|
||||
end
|
||||
|
||||
test "load_body caches post bodies for the lifetime of the context", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
relative_path = "posts/cached-body.md"
|
||||
full_path = Path.join(temp_dir, relative_path)
|
||||
File.mkdir_p!(Path.dirname(full_path))
|
||||
File.write!(full_path, "---\ntitle: Cached\n---\nCached body content\n")
|
||||
|
||||
ctx = RenderContext.build(project.id)
|
||||
|
||||
assert BDS.Generation.Renderers.load_body(ctx, relative_path, nil) ==
|
||||
"Cached body content"
|
||||
|
||||
File.rm!(full_path)
|
||||
|
||||
assert BDS.Generation.Renderers.load_body(ctx, relative_path, nil) ==
|
||||
"Cached body content"
|
||||
end
|
||||
|
||||
test "generate_site batches hash bookkeeping and skips unchanged outputs", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
_post = create_published_post(project, %{title: "Site Post", content: "Site body"})
|
||||
|
||||
assert {:ok, %{generated_files: files}} =
|
||||
Generation.generate_site(project.id, [:core, :single])
|
||||
|
||||
assert files != []
|
||||
|
||||
Enum.each(files, fn file ->
|
||||
disk_path = Path.join([temp_dir, "html", file.relative_path])
|
||||
assert File.exists?(disk_path)
|
||||
|
||||
disk_hash =
|
||||
:crypto.hash(:sha256, File.read!(disk_path)) |> Base.encode16(case: :lower)
|
||||
|
||||
assert disk_hash == file.content_hash
|
||||
end)
|
||||
|
||||
first_by_path = Map.new(files, &{&1.relative_path, &1})
|
||||
|
||||
Process.sleep(5)
|
||||
|
||||
assert {:ok, %{generated_files: second_files}} =
|
||||
Generation.generate_site(project.id, [:core, :single])
|
||||
|
||||
Enum.each(second_files, fn file ->
|
||||
first = Map.fetch!(first_by_path, file.relative_path)
|
||||
assert file.content_hash == first.content_hash
|
||||
|
||||
assert file.updated_at == first.updated_at,
|
||||
"expected unchanged output #{file.relative_path} to keep its updated_at"
|
||||
end)
|
||||
end
|
||||
|
||||
test "full section render issues a bounded number of queries regardless of post count", %{
|
||||
project: project
|
||||
} do
|
||||
for index <- 1..12 do
|
||||
create_published_post(project, %{
|
||||
title: "Query Bound #{index}",
|
||||
content: "Body #{index}",
|
||||
tags: ["tag-#{rem(index, 3)}"],
|
||||
categories: ["article"]
|
||||
})
|
||||
end
|
||||
|
||||
query_count =
|
||||
count_queries(fn ->
|
||||
assert {:ok, %{rendered_url_count: rendered}} =
|
||||
Generation.render_site_section(project.id, :single)
|
||||
|
||||
assert rendered >= 12
|
||||
end)
|
||||
|
||||
# Before the render-context rework this was ~15 queries *per page*; the
|
||||
# bound only holds if all per-page lookups stay preloaded.
|
||||
assert query_count <= 40,
|
||||
"expected a bounded query count for the section render, got #{query_count}"
|
||||
end
|
||||
|
||||
defp count_queries(func) do
|
||||
test_pid = self()
|
||||
ref = make_ref()
|
||||
handler_id = "render-context-query-counter-#{inspect(ref)}"
|
||||
|
||||
:telemetry.attach(
|
||||
handler_id,
|
||||
[:bds, :repo, :query],
|
||||
fn _event, _measurements, _metadata, _config ->
|
||||
send(test_pid, {:query_executed, ref})
|
||||
end,
|
||||
nil
|
||||
)
|
||||
|
||||
func.()
|
||||
:telemetry.detach(handler_id)
|
||||
count_query_messages(ref, 0)
|
||||
end
|
||||
|
||||
defp count_query_messages(ref, acc) do
|
||||
receive do
|
||||
{:query_executed, ^ref} -> count_query_messages(ref, acc + 1)
|
||||
after
|
||||
0 -> acc
|
||||
end
|
||||
end
|
||||
|
||||
defp post_date_parts(post) do
|
||||
datetime = BDS.Persistence.from_unix_ms!(post.created_at)
|
||||
|
||||
[
|
||||
Integer.to_string(datetime.year),
|
||||
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
|
||||
String.pad_leading(Integer.to_string(datetime.day), 2, "0")
|
||||
]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user