713 lines
24 KiB
Elixir
713 lines
24 KiB
Elixir
defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
|
use Mix.Task
|
|
|
|
alias WhoNeedHelp.Repo
|
|
|
|
@shortdoc "Measures keyset pagination plans on an isolated large data set"
|
|
|
|
@moduledoc """
|
|
Seeds and measures the isolated database used by `scripts/db-scale-benchmark.sh`.
|
|
|
|
The task records PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output.
|
|
It intentionally does not enforce an execution-time threshold because timings
|
|
depend on the host. The `after` phase does enforce query correctness and that
|
|
each measured cursor query uses its purpose-built index.
|
|
"""
|
|
|
|
@default_rows 50_000
|
|
|
|
@impl Mix.Task
|
|
def run(arguments) do
|
|
Mix.Task.run("app.start")
|
|
|
|
{options, _rest, invalid} =
|
|
OptionParser.parse(arguments,
|
|
strict: [phase: :string, rows: :integer, output: :string]
|
|
)
|
|
|
|
if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")
|
|
|
|
phase = Keyword.get(options, :phase) || Mix.raise("--phase before|after is required")
|
|
rows = Keyword.get(options, :rows, @default_rows)
|
|
output = Keyword.get(options, :output) || Mix.raise("--output PATH is required")
|
|
|
|
unless phase in ["before", "after"], do: Mix.raise("--phase must be before or after")
|
|
unless rows >= 3, do: Mix.raise("--rows must be at least 3 for relational fixtures")
|
|
|
|
File.mkdir_p!(output)
|
|
|
|
if phase == "before" do
|
|
seed!(rows)
|
|
else
|
|
assert_seed_size!(rows)
|
|
end
|
|
|
|
analyze!()
|
|
plans = collect_plans!(phase, output)
|
|
assert_keyset_pages!()
|
|
|
|
summary = %{
|
|
phase: phase,
|
|
configured_rows_per_large_table: rows,
|
|
captured_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(),
|
|
postgres_version: scalar!("SHOW server_version"),
|
|
table_counts: table_counts(),
|
|
database_bytes: scalar!("SELECT pg_database_size(current_database())"),
|
|
index_bytes: index_sizes(),
|
|
plans: plans
|
|
}
|
|
|
|
summary_path = Path.join(output, "#{phase}-summary.json")
|
|
File.write!(summary_path, Jason.encode_to_iodata!(summary, pretty: true))
|
|
|
|
if phase == "after" do
|
|
assert_expected_indexes_exist!()
|
|
|
|
if rows == @default_rows do
|
|
assert_expected_indexes_used!(plans)
|
|
end
|
|
|
|
write_comparison!(output, summary)
|
|
end
|
|
|
|
Mix.shell().info("database scale #{phase} phase written to #{summary_path}")
|
|
end
|
|
|
|
defp seed!(rows) do
|
|
Repo.transaction(
|
|
fn ->
|
|
Enum.each(seed_statements(rows), &query!/1)
|
|
end,
|
|
timeout: :infinity
|
|
)
|
|
end
|
|
|
|
defp seed_statements(rows) do
|
|
[
|
|
"""
|
|
INSERT INTO users (
|
|
id, email, display_name, confirmed_at, accepted_terms_at, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('user-' || value)::uuid,
|
|
'db-scale-' || value || '@example.invalid',
|
|
'DB scale user ' || value,
|
|
date_trunc('second', now()),
|
|
date_trunc('second', now()),
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO categories (
|
|
id, slug, names, descriptions, mode, inserted_at, updated_at
|
|
)
|
|
VALUES (
|
|
md5('db-scale-category')::uuid,
|
|
'db-scale-help',
|
|
'{"en":"DB scale help"}'::jsonb,
|
|
'{"en":"Synthetic isolated benchmark category"}'::jsonb,
|
|
'help',
|
|
date_trunc('second', now()),
|
|
date_trunc('second', now())
|
|
)
|
|
""",
|
|
"""
|
|
INSERT INTO help_requests (
|
|
id, title, description, location_label, location, status, urgency,
|
|
location_visibility, expires_at, hidden_at, requester_id, category_id,
|
|
inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('request-' || value)::uuid,
|
|
'DB scale request ' || value,
|
|
'Synthetic isolated database scale request ' || value,
|
|
'Kyiv benchmark point',
|
|
ST_SetSRID(ST_MakePoint(30.5 + (value % 100) / 10000.0, 50.4), 4326),
|
|
CASE WHEN value % 4 = 0 THEN 'open' ELSE 'completed' END,
|
|
'now',
|
|
'approximate_public',
|
|
date_trunc('second', now()) + (value % 30 + 1) * interval '1 day',
|
|
CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END,
|
|
md5('user-1')::uuid,
|
|
md5('db-scale-category')::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO help_assignments (
|
|
id, status, handover_code_hash, handover_verified_at,
|
|
proximity_observed_at, helper_movement_observed_at, accepted_at,
|
|
completed_at, request_id, helper_id, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('assignment-' || value)::uuid,
|
|
'completed',
|
|
decode(md5('handover-' || value), 'hex'),
|
|
CASE WHEN value % 2 = 0 THEN date_trunc('second', now()) END,
|
|
CASE WHEN value % 3 = 0 THEN date_trunc('second', now()) END,
|
|
CASE WHEN value % 4 = 0 THEN date_trunc('second', now()) END,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()),
|
|
md5('request-' || value)::uuid,
|
|
md5('user-2')::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now())
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO messages (
|
|
id, body, assignment_id, sender_id, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('message-' || value)::uuid,
|
|
'Synthetic isolated message ' || value,
|
|
md5('assignment-1')::uuid,
|
|
md5('user-' || CASE WHEN value % 2 = 0 THEN 1 ELSE 2 END)::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO reviews (
|
|
id, rating, comment, revealed_at, assignment_id, reviewer_id, reviewee_id,
|
|
inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('review-' || value)::uuid,
|
|
value % 5 + 1,
|
|
'Synthetic isolated review ' || value,
|
|
date_trunc('second', now()),
|
|
md5('assignment-' || value)::uuid,
|
|
md5('user-1')::uuid,
|
|
md5('user-2')::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO reports (
|
|
id, reason, details, status, reporter_id, request_id, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('report-' || value)::uuid,
|
|
'safety',
|
|
'Synthetic isolated report ' || value,
|
|
CASE
|
|
WHEN value % 4 = 0 THEN 'open'
|
|
WHEN value % 4 = 1 THEN 'reviewing'
|
|
WHEN value % 4 = 2 THEN 'resolved'
|
|
ELSE 'dismissed'
|
|
END,
|
|
md5('user-2')::uuid,
|
|
md5('request-' || value)::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO blocks (id, blocker_id, blocked_id, inserted_at)
|
|
SELECT
|
|
md5('block-' || value)::uuid,
|
|
md5('user-2')::uuid,
|
|
md5('user-' || value)::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(3, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO abuse_signals (
|
|
id, kind, status, metadata, subject_id, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('signal-' || value)::uuid,
|
|
'db_scale_' || value,
|
|
CASE
|
|
WHEN value % 3 = 0 THEN 'open'
|
|
WHEN value % 3 = 1 THEN 'reviewed'
|
|
ELSE 'dismissed'
|
|
END,
|
|
'{}'::jsonb,
|
|
md5('user-' || value)::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO category_proposals (
|
|
id, proposed_name, reason, status, proposer_id, mode, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('proposal-' || value)::uuid,
|
|
'DB scale proposal ' || value,
|
|
'Synthetic isolated category proposal ' || value,
|
|
CASE
|
|
WHEN value % 4 = 0 THEN 'open'
|
|
WHEN value % 4 = 1 THEN 'approved'
|
|
WHEN value % 4 = 2 THEN 'rejected'
|
|
ELSE 'merged'
|
|
END,
|
|
md5('user-1')::uuid,
|
|
'help',
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO activities (
|
|
id, title, description, location_label, location, status, starts_at,
|
|
join_deadline, capacity, hidden_at, creator_id, category_id, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('activity-' || value)::uuid,
|
|
'DB scale activity ' || value,
|
|
'Synthetic isolated activity ' || value,
|
|
'Kyiv benchmark point',
|
|
ST_SetSRID(ST_MakePoint(30.5, 50.4 + (value % 100) / 10000.0), 4326),
|
|
CASE
|
|
WHEN value % 3 = 0 THEN 'open'
|
|
WHEN value % 3 = 1 THEN 'completed'
|
|
ELSE 'cancelled'
|
|
END,
|
|
date_trunc('second', now()) + (value + 1000) * interval '1 second',
|
|
date_trunc('second', now()) + (value + 700) * interval '1 second',
|
|
10,
|
|
CASE WHEN value % 30 = 0 THEN date_trunc('second', now()) END,
|
|
md5('user-1')::uuid,
|
|
md5('db-scale-category')::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO activity_participants (
|
|
id, role, status, reviewed_at, activity_id, user_id, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('participant-' || value)::uuid,
|
|
'participant',
|
|
'approved',
|
|
date_trunc('second', now()),
|
|
md5('activity-' || value)::uuid,
|
|
md5('user-' || (value % 100 + 3))::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
UNION ALL
|
|
SELECT
|
|
md5('participant-user-2-' || value)::uuid,
|
|
'participant',
|
|
'approved',
|
|
date_trunc('second', now()),
|
|
md5('activity-' || value)::uuid,
|
|
md5('user-2')::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(100, #{rows}, 100) AS value
|
|
""",
|
|
"""
|
|
INSERT INTO activity_messages (
|
|
id, body, activity_id, sender_id, inserted_at, updated_at
|
|
)
|
|
SELECT
|
|
md5('activity-message-' || value)::uuid,
|
|
'Synthetic isolated activity message ' || value,
|
|
md5('activity-1')::uuid,
|
|
md5('user-1')::uuid,
|
|
date_trunc('second', now()) - value * interval '1 second',
|
|
date_trunc('second', now()) - value * interval '1 second'
|
|
FROM generate_series(1, #{rows}) AS value
|
|
"""
|
|
]
|
|
end
|
|
|
|
defp analyze! do
|
|
query!("""
|
|
ANALYZE users, help_requests, help_assignments, messages, reviews, reports,
|
|
blocks, abuse_signals, category_proposals, activities,
|
|
activity_participants, activity_messages
|
|
""")
|
|
end
|
|
|
|
defp collect_plans!(phase, output) do
|
|
plan_queries()
|
|
|> Map.new(fn {name, query} ->
|
|
plan = explain!(query)
|
|
|
|
File.write!(
|
|
Path.join(output, "#{phase}-#{name}.json"),
|
|
Jason.encode_to_iodata!(plan, pretty: true)
|
|
)
|
|
|
|
{name, summarize_plan(plan)}
|
|
end)
|
|
end
|
|
|
|
defp plan_queries do
|
|
viewer = "md5('user-1')::uuid"
|
|
|
|
%{
|
|
"help_discovery" => """
|
|
SELECT id FROM help_requests
|
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
|
AND requester_id NOT IN (
|
|
SELECT blocked_id FROM blocks WHERE blocker_id = #{viewer}
|
|
)
|
|
AND requester_id NOT IN (
|
|
SELECT blocker_id FROM blocks WHERE blocked_id = #{viewer}
|
|
)
|
|
ORDER BY expires_at ASC, id ASC LIMIT 25
|
|
""",
|
|
"help_requester" => """
|
|
SELECT id FROM help_requests
|
|
WHERE requester_id = md5('user-1')::uuid
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"activity_discovery" => """
|
|
SELECT id FROM activities
|
|
WHERE status = 'open' AND hidden_at IS NULL
|
|
AND starts_at > now() AND join_deadline > now()
|
|
ORDER BY starts_at ASC, id ASC LIMIT 25
|
|
""",
|
|
"activity_participant" => """
|
|
SELECT activities.id
|
|
FROM activities
|
|
INNER JOIN activity_participants
|
|
ON activity_participants.activity_id = activities.id
|
|
AND activity_participants.user_id = md5('user-2')::uuid
|
|
AND activity_participants.status IN ('requested', 'approved')
|
|
WHERE activities.creator_id NOT IN (
|
|
SELECT blocked_id FROM blocks WHERE blocker_id = md5('user-2')::uuid
|
|
)
|
|
AND activities.creator_id NOT IN (
|
|
SELECT blocker_id FROM blocks WHERE blocked_id = md5('user-2')::uuid
|
|
)
|
|
ORDER BY activities.starts_at DESC, activities.id DESC LIMIT 25
|
|
""",
|
|
"messages" => """
|
|
SELECT id FROM messages
|
|
WHERE assignment_id = md5('assignment-1')::uuid
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 51
|
|
""",
|
|
"activity_messages" => """
|
|
SELECT id FROM activity_messages
|
|
WHERE activity_id = md5('activity-1')::uuid
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 51
|
|
""",
|
|
"visible_reviews" => """
|
|
SELECT id FROM reviews
|
|
WHERE reviewee_id = md5('user-2')::uuid AND revealed_at IS NOT NULL
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"reports_open" => """
|
|
SELECT id FROM reports
|
|
WHERE status = 'open'
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"reports_all" => """
|
|
SELECT id FROM reports
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"blocks" => """
|
|
SELECT id FROM blocks
|
|
WHERE blocker_id = md5('user-2')::uuid
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"abuse_signals" => """
|
|
SELECT id FROM abuse_signals
|
|
WHERE status = 'open'
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"proposals_open" => """
|
|
SELECT id FROM category_proposals
|
|
WHERE status = 'open'
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"proposals_all" => """
|
|
SELECT id FROM category_proposals
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"users" => """
|
|
SELECT id FROM users
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
"reputation_helper" => """
|
|
SELECT
|
|
count(help_assignments.id),
|
|
count(DISTINCT help_requests.requester_id),
|
|
count(help_assignments.id) FILTER (
|
|
WHERE help_assignments.handover_verified_at IS NOT NULL
|
|
),
|
|
count(help_assignments.id) FILTER (
|
|
WHERE help_assignments.proximity_observed_at IS NOT NULL
|
|
AND help_assignments.helper_movement_observed_at IS NOT NULL
|
|
)
|
|
FROM help_assignments
|
|
INNER JOIN help_requests
|
|
ON help_requests.id = help_assignments.request_id
|
|
WHERE help_assignments.status = 'completed'
|
|
AND help_assignments.helper_id = md5('user-2')::uuid
|
|
""",
|
|
"leaderboard_aggregate" => """
|
|
SELECT
|
|
help_assignments.helper_id,
|
|
count(help_assignments.id),
|
|
count(DISTINCT help_requests.requester_id),
|
|
count(DISTINCT help_requests.requester_id) FILTER (
|
|
WHERE help_assignments.proximity_observed_at IS NOT NULL
|
|
AND help_assignments.helper_movement_observed_at IS NOT NULL
|
|
),
|
|
count(DISTINCT help_requests.requester_id) FILTER (
|
|
WHERE help_assignments.handover_verified_at IS NOT NULL
|
|
)
|
|
FROM help_assignments
|
|
INNER JOIN help_requests
|
|
ON help_requests.id = help_assignments.request_id
|
|
WHERE help_assignments.status = 'completed'
|
|
GROUP BY help_assignments.helper_id
|
|
"""
|
|
}
|
|
end
|
|
|
|
defp explain!(query) do
|
|
result =
|
|
Repo.query!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{query}", [], timeout: :infinity)
|
|
|
|
case result.rows do
|
|
[[[plan | _]]] when is_map(plan) -> plan
|
|
[[plan]] when is_map(plan) -> plan
|
|
rows -> Mix.raise("unexpected EXPLAIN JSON result: #{inspect(rows, limit: 2)}")
|
|
end
|
|
end
|
|
|
|
defp summarize_plan(plan) do
|
|
nodes = flatten_nodes(Map.fetch!(plan, "Plan"))
|
|
|
|
%{
|
|
planning_time_ms: plan["Planning Time"],
|
|
execution_time_ms: plan["Execution Time"],
|
|
node_types: nodes |> Enum.map(& &1["Node Type"]) |> Enum.reject(&is_nil/1),
|
|
index_names: nodes |> Enum.map(& &1["Index Name"]) |> Enum.reject(&is_nil/1) |> Enum.uniq(),
|
|
shared_hit_blocks: plan["Plan"]["Shared Hit Blocks"] || 0,
|
|
shared_read_blocks: plan["Plan"]["Shared Read Blocks"] || 0,
|
|
returned_rows: plan["Plan"]["Actual Rows"]
|
|
}
|
|
end
|
|
|
|
defp flatten_nodes(node) do
|
|
[node | Enum.flat_map(Map.get(node, "Plans", []), &flatten_nodes/1)]
|
|
end
|
|
|
|
defp expected_indexes do
|
|
%{
|
|
"help_discovery" => "help_requests_discovery_cursor_index",
|
|
"help_requester" => "help_requests_requester_cursor_index",
|
|
"activity_discovery" => "activities_discovery_cursor_index",
|
|
"activity_participant" => "activity_participants_user_id_status_index",
|
|
"messages" => "messages_assignment_cursor_index",
|
|
"activity_messages" => "activity_messages_activity_cursor_index",
|
|
"visible_reviews" => "reviews_visible_cursor_index",
|
|
"reports_open" => "reports_cursor_index",
|
|
"reports_all" => "reports_cursor_index",
|
|
"blocks" => "blocks_blocker_cursor_index",
|
|
"abuse_signals" => "abuse_signals_status_cursor_index",
|
|
"proposals_open" => "category_proposals_cursor_index",
|
|
"proposals_all" => "category_proposals_cursor_index",
|
|
"users" => "users_moderation_cursor_index"
|
|
}
|
|
end
|
|
|
|
defp assert_expected_indexes_exist! do
|
|
existing =
|
|
Repo.query!("SELECT indexname FROM pg_indexes WHERE schemaname = current_schema()").rows
|
|
|> List.flatten()
|
|
|> MapSet.new()
|
|
|
|
missing =
|
|
expected_indexes()
|
|
|> Map.values()
|
|
|> Enum.uniq()
|
|
|> Enum.reject(&MapSet.member?(existing, &1))
|
|
|
|
if missing != [], do: Mix.raise("missing cursor indexes: #{Enum.join(missing, ", ")}")
|
|
end
|
|
|
|
defp assert_expected_indexes_used!(plans) do
|
|
failures =
|
|
Enum.reject(expected_indexes(), fn {name, index} ->
|
|
index in get_in(plans, [name, :index_names])
|
|
end)
|
|
|
|
if failures != [] do
|
|
details =
|
|
Enum.map_join(failures, ", ", fn {name, index} ->
|
|
"#{name} expected #{index}, observed #{inspect(get_in(plans, [name, :index_names]))}"
|
|
end)
|
|
|
|
Mix.raise("cursor plan assertions failed: #{details}")
|
|
end
|
|
end
|
|
|
|
defp assert_keyset_pages! do
|
|
{first_ids, cursor} = fetch_help_page(nil)
|
|
{second_ids, _cursor} = fetch_help_page(cursor)
|
|
|
|
if length(first_ids) != 25 or length(second_ids) != 25 do
|
|
Mix.raise("keyset page assertion expected two full 25-row pages")
|
|
end
|
|
|
|
if MapSet.disjoint?(MapSet.new(first_ids), MapSet.new(second_ids)) == false do
|
|
Mix.raise("keyset page assertion found duplicate rows across pages")
|
|
end
|
|
|
|
expected =
|
|
Repo.query!("""
|
|
SELECT id FROM help_requests
|
|
WHERE requester_id = md5('user-1')::uuid
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 50
|
|
""").rows
|
|
|> List.flatten()
|
|
|
|
if first_ids ++ second_ids != expected do
|
|
Mix.raise("keyset page assertion found a gap or order mismatch")
|
|
end
|
|
end
|
|
|
|
defp fetch_help_page(nil) do
|
|
result =
|
|
Repo.query!("""
|
|
SELECT id, inserted_at FROM help_requests
|
|
WHERE requester_id = md5('user-1')::uuid
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""")
|
|
|
|
page_result(result)
|
|
end
|
|
|
|
defp fetch_help_page({inserted_at, id}) do
|
|
result =
|
|
Repo.query!(
|
|
"""
|
|
SELECT id, inserted_at FROM help_requests
|
|
WHERE requester_id = md5('user-1')::uuid
|
|
AND (inserted_at, id) < ($1, $2)
|
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
|
""",
|
|
[inserted_at, id]
|
|
)
|
|
|
|
page_result(result)
|
|
end
|
|
|
|
defp page_result(result) do
|
|
ids = Enum.map(result.rows, &hd/1)
|
|
[id, inserted_at] = List.last(result.rows)
|
|
{ids, {inserted_at, id}}
|
|
end
|
|
|
|
defp assert_seed_size!(rows) do
|
|
actual = scalar!("SELECT count(*) FROM help_requests")
|
|
if actual != rows, do: Mix.raise("expected #{rows} seeded help requests, found #{actual}")
|
|
end
|
|
|
|
defp table_counts do
|
|
~w(
|
|
users help_requests help_assignments messages reviews reports blocks
|
|
abuse_signals category_proposals activities activity_participants activity_messages
|
|
)
|
|
|> Map.new(fn table -> {table, scalar!("SELECT count(*) FROM #{table}")} end)
|
|
end
|
|
|
|
defp index_sizes do
|
|
Repo.query!("""
|
|
SELECT indexrelname, pg_relation_size(indexrelid)
|
|
FROM pg_stat_user_indexes
|
|
WHERE indexrelname LIKE '%cursor_index'
|
|
ORDER BY indexrelname
|
|
""").rows
|
|
|> Map.new(fn [name, bytes] -> {name, bytes} end)
|
|
end
|
|
|
|
defp write_comparison!(output, after_summary) do
|
|
before_path = Path.join(output, "before-summary.json")
|
|
|
|
before =
|
|
before_path
|
|
|> File.read!()
|
|
|> Jason.decode!()
|
|
|
|
comparisons =
|
|
Map.new(after_summary.plans, fn {name, after_plan} ->
|
|
before_plan = get_in(before, ["plans", name])
|
|
|
|
{name,
|
|
%{
|
|
before_execution_time_ms: before_plan["execution_time_ms"],
|
|
after_execution_time_ms: after_plan.execution_time_ms,
|
|
observed_execution_ratio:
|
|
ratio(before_plan["execution_time_ms"], after_plan.execution_time_ms),
|
|
before_index_names: before_plan["index_names"],
|
|
after_index_names: after_plan.index_names
|
|
}}
|
|
end)
|
|
|
|
comparison = %{
|
|
note:
|
|
"Observed timings describe this isolated run and are not a portable minimum or latency gate.",
|
|
configured_rows_per_large_table: after_summary.configured_rows_per_large_table,
|
|
plans: comparisons
|
|
}
|
|
|
|
File.write!(
|
|
Path.join(output, "comparison.json"),
|
|
Jason.encode_to_iodata!(comparison, pretty: true)
|
|
)
|
|
|
|
markdown =
|
|
[
|
|
"# Isolated database scale measurement\n\n",
|
|
"Rows configured per large table: ",
|
|
Integer.to_string(after_summary.configured_rows_per_large_table),
|
|
"\n\n",
|
|
"Timings below are observations from this run, not a portable minimum or an SLO.\n\n",
|
|
"| Query | Before, ms | After, ms | Before indexes | After indexes |\n",
|
|
"| --- | ---: | ---: | --- | --- |\n",
|
|
Enum.map(comparisons, fn {name, values} ->
|
|
[
|
|
"| ",
|
|
name,
|
|
" | ",
|
|
format_ms(values.before_execution_time_ms),
|
|
" | ",
|
|
format_ms(values.after_execution_time_ms),
|
|
" | ",
|
|
Enum.join(values.before_index_names, ", "),
|
|
" | ",
|
|
Enum.join(values.after_index_names, ", "),
|
|
" |\n"
|
|
]
|
|
end)
|
|
]
|
|
|
|
File.write!(Path.join(output, "README.md"), markdown)
|
|
end
|
|
|
|
defp ratio(before_ms, after_ms)
|
|
when is_number(before_ms) and is_number(after_ms) and after_ms > 0,
|
|
do: Float.round(before_ms / after_ms, 3)
|
|
|
|
defp ratio(_before_ms, _after_ms), do: nil
|
|
|
|
defp format_ms(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 3)
|
|
defp format_ms(value), do: to_string(value)
|
|
|
|
defp scalar!(sql) do
|
|
case Repo.query!(sql).rows do
|
|
[[value]] -> value
|
|
rows -> Mix.raise("expected one scalar value, got: #{inspect(rows)}")
|
|
end
|
|
end
|
|
|
|
defp query!(sql), do: Repo.query!(sql, [], timeout: :infinity)
|
|
end
|