who_need_help/lib/mix/tasks/wnh.db_scale_benchmark.ex

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