Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
33 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/pleroma/web/activity_pub/home_timeline.ex b/lib/pleroma/web/activity_pub/home_timeline.ex
index 8864662b6..7df47bf7a 100644
--- a/lib/pleroma/web/activity_pub/home_timeline.ex
+++ b/lib/pleroma/web/activity_pub/home_timeline.ex
@@ -1,265 +1,267 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.HomeTimeline do
@moduledoc false
import Ecto.Query
alias Pleroma.Activity
alias Pleroma.Pagination
alias Pleroma.Repo
alias Pleroma.User
@minimum_recipients 128
@candidate_limit 8192
@budget_ms 250
# Home selection must be exact, but search still benefits from the configured
# fuzzy GIN limit. A savepoint also restores settings inside caller transactions
# (including SQL Sandbox), where SET LOCAL alone would leak into later queries.
# Only read-only selection belongs here, never rendering or job insertion.
def with_exact_queries(fun) do
if Repo.in_transaction?() do
exact_queries(fun)
else
{:ok, result} = Repo.transaction(fn -> exact_queries(fun) end)
result
end
end
defp exact_queries(fun) do
read_only_savepoint("home_timeline", fn ->
Repo.query!("SET LOCAL gin_fuzzy_search_limit = 0")
fun.()
end)
end
def fetch(recipients, opts, build_query, rejected_actors) do
opts = Map.put(opts, :skip_extra_order, true)
result =
cond do
opts[:followed_hashtags] not in [nil, []] -> :fallback
length(recipients) >= @minimum_recipients -> attempt(recipients, opts, build_query)
per_owner?(opts) -> {:ok, per_owner(recipients, opts, build_query, rejected_actors)}
true -> :fallback
end
case result do
{:ok, activities} ->
activities
:fallback ->
build_query.(Activity, recipients, :overlap)
|> Pagination.fetch_paginated(opts, :keyset, nil)
end
end
defp per_owner?(%{user: %User{}, type: [_ | _] = types}), do: Enum.all?(types, &is_binary/1)
defp per_owner?(_), do: false
# Few recipients: walk each (owner, type) pair through the actor/type index
# instead of the whole id index. Owners are the users whose followers
# collection is a recipient, plus the user. Unlike the overlap query (and like
# Mastodon), a post addressed to a followers collection by anyone but its
# owner (third parties, PeerTube channels) only appears if it also addresses
# the user or a list.
defp per_owner(recipients, opts, build_query, rejected_actors) do
options = Pagination.cast_params(opts)
users =
from(u in User,
where: u.follower_address in ^recipients,
select: %{ap_id: u.ap_id, follower_address: u.follower_address, local: u.local}
)
|> Repo.all()
# Skip owners whose posts the filters always reject; their whole index
# slice would be scanned for nothing.
rejected = rejected_actors.()
owners =
for %{ap_id: ap_id, local: local} <- users,
local or opts[:local_only] != true,
ap_id not in rejected,
do: ap_id
owners = Enum.uniq([opts[:user].ap_id | owners])
collections = Enum.map(users, & &1.follower_address)
owner_ids = owner_ids(owners, recipients, opts, options, build_query)
bound = page_bound(owner_ids, options)
case owner_ids ++ addressed_ids(recipients -- collections, options, build_query, bound) do
[] -> []
ids -> reload(ids, recipients, opts, build_query)
end
end
# A full page of owner posts ends where the page ends: a post addressed to
# the user beyond its last id cannot make the page.
+ defp page_bound([], _options), do: nil
+
defp page_bound(ids, options) do
if length(ids) >= Pagination.page_size(options) do
{if(ascending?(options), do: :below, else: :above), List.last(ids)}
end
end
# the order Pagination gives a keyset page
defp ascending?(%{min_id: _}), do: true
defp ascending?(%{max_id: _}), do: false
defp ascending?(options), do: options[:order_asc] == true
defp owner_ids(owners, recipients, opts, options, build_query) do
# Each pair contributes at most one page from an ordered index scan.
page =
build_query.(Activity, recipients, :owner)
|> where(
[a],
a.actor == parent_as(:owner).actor and
fragment("?->>'type'", a.data) == parent_as(:owner).type
)
|> page_ids(options)
from(
o in fragment(
"SELECT * FROM unnest(?::text[]) AS actor CROSS JOIN unnest(?::text[]) AS type",
^owners,
^opts[:type]
),
as: :owner,
inner_lateral_join: c in subquery(page),
on: true,
as: :candidate,
select: c.id
)
|> Pagination.paginate(options, :keyset, :candidate)
|> Repo.all()
end
# Direct addressing (the user, lists, unknown collections) is rare, so GIN fits.
defp addressed_ids([], _options, _build_query, _bound), do: []
defp addressed_ids(recipients, options, build_query, bound) do
build_query.(Activity, recipients, :addressed)
|> within(bound)
|> page_ids(options)
|> Repo.all()
|> Enum.map(& &1.id)
end
defp within(query, nil), do: query
defp within(query, {:above, id}), do: where(query, [a], a.id > ^id)
defp within(query, {:below, id}), do: where(query, [a], a.id < ^id)
defp page_ids(query, options) do
query
|> exclude(:preload)
|> exclude(:select)
|> select([a], %{id: a.id})
|> Pagination.paginate(options)
end
defp attempt(recipients, opts, build_query) do
read_only_savepoint("home_timeline_fast", fn ->
deadline = System.monotonic_time(:millisecond) + @budget_ms
set_timeout(@budget_ms)
options = Pagination.cast_params(opts)
rows = select_candidates(recipients, options, opts, build_query)
ids = for {id, _count} <- rows, not is_nil(id), do: id
{_id, count} = hd(rows)
cond do
length(ids) < Pagination.page_size(options) and count > @candidate_limit ->
:fallback
ids == [] ->
{:ok, []}
true ->
hydrate(ids, recipients, opts, build_query, deadline)
end
end)
rescue
error in Postgrex.Error ->
if error.postgres[:code] == :query_canceled do
:fallback
else
reraise error, __STACKTRACE__
end
end
defp select_candidates(recipients, options, opts, build_query) do
# The extra ID proves whether the requested range was exhausted. Metadata and
# filtered IDs are selected in ONE snapshot, including when no posts match.
range =
Activity
|> candidate_order(opts)
|> Pagination.paginate(options)
candidates =
range
|> limit(^(@candidate_limit + 1))
|> select([a], %{id: a.id})
# Both bounded scans share the same range, order and statement snapshot.
# Keep the activity scan ordered and limited BEFORE every filter; joining
# candidate IDs back to activities can lose that order and force a full sort.
source = range |> limit(^@candidate_limit) |> subquery()
page = build_query.(source, recipients, :exists) |> page_ids(options)
metadata = from(c in "home_timeline_candidates", select: %{count: count(c.id)})
from(m in subquery(metadata),
left_join: a in subquery(page),
on: true,
select: {a.id, m.count}
)
|> with_cte("home_timeline_candidates", as: ^candidates, materialized: true)
|> Repo.all()
end
defp candidate_order(query, %{order: direction}) when direction in [:asc, :desc] do
order_by(query, [a], [{^direction, a.id}])
end
defp candidate_order(query, _), do: query
defp hydrate(ids, recipients, opts, build_query, deadline) do
remaining = deadline - System.monotonic_time(:millisecond)
if remaining > 0 do
set_timeout(remaining)
{:ok, reload(ids, recipients, opts, build_query)}
else
:fallback
end
end
# Reload at most one page through the same filters and typed preloads. A
# concurrent delete/visibility change may remove a post, never add one.
defp reload(ids, recipients, opts, build_query) do
build_query.(Activity, recipients, :exists)
|> where([a], a.id in ^ids)
|> Pagination.fetch_paginated(opts, :keyset, nil)
end
defp set_timeout(milliseconds) do
Repo.query!("SELECT set_config('statement_timeout', $1, true)", ["#{milliseconds}ms"])
end
defp read_only_savepoint(name, fun) do
Repo.query!("SAVEPOINT #{name}")
try do
fun.()
after
# Roll back even on success to restore the caller's settings. This also
# recovers an aborted transaction after PostgreSQL cancels a slow attempt.
Repo.query!("ROLLBACK TO SAVEPOINT #{name}")
Repo.query!("RELEASE SAVEPOINT #{name}")
end
end
end
diff --git a/test/pleroma/web/activity_pub/home_timeline_test.exs b/test/pleroma/web/activity_pub/home_timeline_test.exs
index 173ac8351..e2b84b92f 100644
--- a/test/pleroma/web/activity_pub/home_timeline_test.exs
+++ b/test/pleroma/web/activity_pub/home_timeline_test.exs
@@ -1,705 +1,706 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.HomeTimelineTest do
use Pleroma.DataCase
import Mock
require Pleroma.Constants
import Pleroma.Factory
alias Pleroma.Pagination
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.ActivityPub.ActivityPub
defp setting(name), do: Repo.query!("SELECT current_setting($1)", [name]).rows |> hd() |> hd()
describe "exact home timeline queries" do
test "uses exact GIN only for home selection and restores the caller's setting" do
user = insert(:user)
Repo.query!("SET LOCAL gin_fuzzy_search_limit = 321")
with_mock Pagination, [:passthrough],
fetch_paginated: fn _query, _opts, :keyset, nil ->
send(self(), {:gin_limit, setting("gin_fuzzy_search_limit")})
[]
end do
assert ActivityPub.fetch_home_activities([user.ap_id], %{user: user}) == []
assert_received {:gin_limit, "0"}
assert setting("gin_fuzzy_search_limit") == "321"
assert ActivityPub.fetch_activities([user.ap_id], %{user: user}) == []
assert_received {:gin_limit, "321"}
end
end
test "restores settings inside an existing transaction" do
user = insert(:user)
assert {:ok, :ok} =
Repo.transaction(fn ->
Repo.query!("SET LOCAL gin_fuzzy_search_limit = 123")
ActivityPub.fetch_home_activities([user.ap_id], %{user: user})
assert setting("gin_fuzzy_search_limit") == "123"
:ok
end)
end
test "restores the caller's transaction after a database error" do
user = insert(:user)
Repo.query!("SET LOCAL gin_fuzzy_search_limit = 123")
with_mock Pagination, [:passthrough],
fetch_paginated: fn _query, _opts, :keyset, nil ->
Repo.query!("SELECT 1 / 0")
end do
assert_raise Postgrex.Error, fn ->
ActivityPub.fetch_home_activities([user.ap_id], %{user: user})
end
end
assert setting("gin_fuzzy_search_limit") == "123"
assert Repo.query!("SELECT 1").rows == [[1]]
end
end
describe "bounded fast selection" do
setup do
user = insert(:user)
recipients = [user.ap_id | Enum.map(1..127, &"https://example.org/followers/#{&1}")]
opts = %{user: user, type: ["Create", "Announce"], limit: 2}
%{user: user, recipients: recipients, opts: opts}
end
test "gates on unique recipients and excludes followed hashtags", ctx do
for {recipients, opts} <- [
{Enum.take(ctx.recipients, 127), ctx.opts},
{List.duplicate(ctx.user.ap_id, 128), ctx.opts},
{ctx.recipients, Map.put(ctx.opts, :followed_hashtags, [123])}
] do
{[], queries} = queries(fn -> ActivityPub.fetch_home_activities(recipients, opts) end)
refute fast?(queries)
end
{[], queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts) end)
assert fast?(queries)
refute fallback?(queries)
end
test "returns a full page with self posts, recipient posts, and preloaded objects", ctx do
own = insert(:note_activity, user: ctx.user)
addressed =
insert(:note_activity,
recipients: [ctx.user.ap_id],
data_attrs: %{"to" => [ctx.user.ap_id]}
)
insert(:note_activity)
{activities, queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts) end)
assert Enum.map(activities, & &1.id) == [own.id, addressed.id]
assert Enum.all?(activities, &Ecto.assoc_loaded?(&1.object))
assert fast?(queries)
refute fallback?(queries)
end
test "returns exhausted partial and empty polling pages without fallback", ctx do
old = insert(:note_activity, user: ctx.user)
newest = insert(:note_activity, user: ctx.user)
for {since_id, expected} <- [{old.id, [newest.id]}, {newest.id, []}] do
{activities, queries} =
queries(fn ->
ActivityPub.fetch_home_activities(
ctx.recipients,
Map.put(ctx.opts, :since_id, since_id)
)
end)
assert Enum.map(activities, & &1.id) == expected
assert fast?(queries)
refute fallback?(queries)
end
end
test "falls back when sparse matches lie beyond the candidate window", ctx do
old = insert(:note_activity, user: ctx.user)
insert_gap(8192, ctx.user)
newest = insert(:note_activity, user: ctx.user)
{activities, queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts) end)
assert Enum.map(activities, & &1.id) == [old.id, newest.id]
assert fast?(queries)
assert fallback?(queries)
end
test "distinguishes exactly 8192 candidates from a truncated empty window", ctx do
old = insert(:note_activity, user: ctx.user)
insert_gap(8191, ctx.user)
{activities, first_queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts) end)
assert Enum.map(activities, & &1.id) == [old.id]
assert fast?(first_queries)
refute fallback?(first_queries)
insert_gap(1, ctx.user)
{activities, second_queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts) end)
assert Enum.map(activities, & &1.id) == [old.id]
assert fast?(second_queries)
assert fallback?(second_queries)
end
test "applies min, max and since bounds before bounding candidates", ctx do
oldest = insert(:note_activity, user: ctx.user)
middle = insert(:note_activity, user: ctx.user)
newest = insert(:note_activity, user: ctx.user)
insert_gap(8193, ctx.user)
for bounds <- [
%{max_id: newest.id},
%{min_id: oldest.id, max_id: newest.id},
%{since_id: oldest.id, max_id: newest.id},
%{min_id: oldest.id, limit: "1"},
%{min_id: middle.id, order_asc: true, limit: 1}
] do
opts = Map.merge(ctx.opts, bounds)
expected = ActivityPub.fetch_activities(ctx.recipients, opts) |> Enum.map(& &1.id)
{activities, queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, opts) end)
assert Enum.map(activities, & &1.id) == expected
assert fast?(queries)
refute fallback?(queries)
end
end
test "preserves block, mute, media, locality and visibility filters", ctx do
author = insert(:user)
muted = insert(:user)
blocked = insert(:user)
Pleroma.User.mute(ctx.user, muted)
Pleroma.User.block(ctx.user, blocked)
for user <- [author, muted, blocked] do
insert(:note_activity,
user: user,
recipients: [ctx.user.ap_id],
data_attrs: %{"to" => [ctx.user.ap_id]}
)
end
insert(:note_activity, user: ctx.user, local: false)
insert(:note_activity, user: ctx.user)
media =
insert(:note_activity,
user: ctx.user,
note:
insert(:note,
user: ctx.user,
data: %{
"attachment" => [%{"type" => "Image", "url" => "https://example.org/image.png"}]
}
)
)
{:ok, _bookmark} = Pleroma.Bookmark.create(ctx.user.id, media.id)
for filters <- [
%{},
%{with_muted: true},
%{local_only: true},
%{remote: true},
%{only_media: true},
%{exclude_visibilities: ["public"]}
] do
opts =
Map.merge(
ctx.opts,
Map.merge(%{blocking_user: ctx.user, muting_user: ctx.user, limit: 40}, filters)
)
expected = ActivityPub.fetch_activities(ctx.recipients, opts)
{activities, queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, opts) end)
assert activities == expected
assert fast?(queries)
refute fallback?(queries)
end
end
test "preserves reply filtering and the virtual thread mute field", ctx do
parent = insert(:note_activity, user: ctx.user)
reply =
insert(:note_activity,
user: ctx.user,
note: insert(:note, user: ctx.user, data: %{"inReplyTo" => parent.data["object"]})
)
{:ok, _} = Pleroma.ThreadMute.add_mute(ctx.user.id, reply.data["context"])
opts =
Map.merge(ctx.opts, %{
with_muted: true,
muting_user: ctx.user,
reply_filtering_user: ctx.user
})
for filters <- [
%{},
%{reply_visibility: "self"},
%{reply_visibility: "following"},
%{exclude_replies: true}
] do
opts = Map.merge(opts, filters)
expected = ActivityPub.fetch_activities(ctx.recipients, opts)
{activities, queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, opts) end)
assert activities == expected
assert fast?(queries)
refute fallback?(queries)
end
activities = ActivityPub.fetch_home_activities(ctx.recipients, opts)
assert Enum.find(activities, &(&1.id == reply.id)).thread_muted?
end
test "includes list memberships in the gate and preserves list bcc postprocessing", ctx do
owner = insert(:user)
{:ok, list} = Pleroma.List.create(%{title: "members"}, owner)
{:ok, list} = Pleroma.List.follow(list, ctx.user)
activity =
insert(:note_activity,
user: owner,
recipients: [list.ap_id, Pleroma.Constants.as_public()],
data_attrs: %{"bcc" => [list.ap_id], "cc" => [], "to" => [ctx.user.ap_id]}
)
recipients = Enum.take(ctx.recipients, 127)
{activities, queries} =
queries(fn -> ActivityPub.fetch_home_activities(recipients, ctx.opts) end)
assert [result] = activities
assert result.id == activity.id
assert result.data["cc"] == [ctx.user.ap_id]
assert fast?(queries)
refute fallback?(queries)
end
test "uses the configured default and maximum page sizes", ctx do
for _ <- 1..41, do: insert(:note_activity, user: ctx.user)
for opts <- [Map.delete(ctx.opts, :limit), Map.put(ctx.opts, :limit, "100")] do
expected = ActivityPub.fetch_activities(ctx.recipients, opts)
{activities, queries} =
queries(fn -> ActivityPub.fetch_home_activities(ctx.recipients, opts) end)
assert activities == expected
assert fast?(queries)
refute fallback?(queries)
end
end
test "does not swallow non-cancellation errors in the fast query", ctx do
Repo.query!("SET LOCAL gin_fuzzy_search_limit = 123")
Repo.query!("SET LOCAL statement_timeout = '5s'")
with_mock Repo, [:passthrough],
all: fn query ->
{sql, _} = Ecto.Adapters.SQL.to_sql(:all, Repo, query)
if String.contains?(sql, "home_timeline_candidates"),
do: Repo.query!("SELECT 1 / 0"),
else: :meck.passthrough([query])
end do
error =
assert_raise Postgrex.Error, fn ->
ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts)
end
assert error.postgres.code == :division_by_zero
end
assert setting("statement_timeout") == "5s"
assert setting("gin_fuzzy_search_limit") == "123"
end
test "also recovers when bounded hydration times out", ctx do
activity = insert(:note_activity, user: ctx.user)
with_mock Repo, [:passthrough],
all: fn query ->
{sql, _} = Ecto.Adapters.SQL.to_sql(:all, Repo, query)
if String.contains?(sql, "unnest(") and
not String.contains?(sql, "home_timeline_candidates") do
send(self(), :hydration_timeout)
Repo.query!("SELECT pg_sleep(1)")
else
:meck.passthrough([query])
end
end do
assert [result] = ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts)
assert result.id == activity.id
assert_received :hydration_timeout
end
end
test "recovers from a real statement timeout and restores both settings", ctx do
activity = insert(:note_activity, user: ctx.user)
Repo.query!("SET LOCAL statement_timeout = '5s'")
Repo.query!("SET LOCAL gin_fuzzy_search_limit = 123")
with_mock Repo, [:passthrough],
all: fn query ->
{sql, _} = Ecto.Adapters.SQL.to_sql(:all, Repo, query)
if String.contains?(sql, "home_timeline_candidates") do
assert setting("gin_fuzzy_search_limit") == "0"
send(self(), :timed_fast_query)
Repo.query!("SELECT pg_sleep(1)")
else
result = :meck.passthrough([query])
if String.contains?(sql, " && ") do
assert setting("gin_fuzzy_search_limit") == "0"
assert setting("statement_timeout") == "5s"
end
result
end
end do
assert [result] = ActivityPub.fetch_home_activities(ctx.recipients, ctx.opts)
assert result.id == activity.id
assert_received :timed_fast_query
end
assert setting("statement_timeout") == "5s"
assert setting("gin_fuzzy_search_limit") == "123"
assert Repo.query!("SELECT 1").rows == [[1]]
end
end
describe "per-owner selection for few follows" do
setup do
user = insert(:user)
followed = insert(:user)
other = insert(:user)
{:ok, user, followed} = User.follow(user, followed)
opts = %{user: user, type: ["Create", "Announce"], limit: 20}
%{user: user, followed: followed, other: other, opts: opts}
end
test "returns followed, own, direct and list posts but not foreign DMs", ctx do
stranger = insert(:user)
{:ok, list} = Pleroma.List.create(%{title: "members"}, stranger)
{:ok, _list} = Pleroma.List.follow(list, ctx.user)
expected =
for visibility <- ["public", "unlisted", "private"] do
{:ok, post} = CommonAPI.post(ctx.followed, %{status: "hi", visibility: visibility})
post
end
{:ok, original} = CommonAPI.post(ctx.other, %{status: "boost me"})
{:ok, announce} = CommonAPI.repeat(original.id, ctx.followed)
{:ok, own} = CommonAPI.post(ctx.user, %{status: "mine", visibility: "private"})
{:ok, dm} =
CommonAPI.post(ctx.other, %{
status: "@#{ctx.user.nickname} hi",
visibility: "direct"
})
list_post =
insert(:note_activity,
user: stranger,
recipients: [list.ap_id, Pleroma.Constants.as_public()],
data_attrs: %{"bcc" => [list.ap_id], "cc" => [], "to" => [ctx.user.ap_id]}
)
{:ok, _foreign_dm} =
CommonAPI.post(ctx.followed, %{
status: "@#{ctx.other.nickname} secret",
visibility: "direct"
})
{:ok, _unrelated} = CommonAPI.post(ctx.other, %{status: "not followed"})
{activities, queries} = home(ctx.user, ctx.opts)
assert ids(activities) == ids(expected ++ [announce, own, dm, list_post])
assert per_owner?(queries)
refute fast?(queries)
end
test "drops third-party posts addressed only to a followers collection", ctx do
followers = ctx.followed.follower_address
_third_party =
insert(:note_activity,
user: ctx.other,
recipients: [followers],
data_attrs: %{"to" => [followers], "cc" => []}
)
{:ok, post} = CommonAPI.post(ctx.followed, %{status: "hi"})
{activities, queries} = home(ctx.user, ctx.opts)
assert ids(activities) == [post.id]
assert per_owner?(queries)
end
test "preserves block and mute filters", ctx do
muted = insert(:user)
blocked = insert(:user)
{:ok, user, _} = User.follow(ctx.user, muted)
{:ok, user, _} = User.follow(user, blocked)
{:ok, _} = User.mute(user, muted)
{:ok, _} = User.block(user, blocked)
{:ok, visible} = CommonAPI.post(ctx.followed, %{status: "hi"})
{:ok, muted_post} = CommonAPI.post(muted, %{status: "muted"})
{:ok, _} = CommonAPI.post(blocked, %{status: "blocked"})
opts = Map.merge(ctx.opts, %{muting_user: user, blocking_user: user})
{activities, queries} = home(user, opts)
assert ids(activities) == [visible.id]
assert per_owner?(queries)
{activities, _} = home(user, Map.put(opts, :with_muted, true))
assert ids(activities) == [visible.id, muted_post.id]
end
test "keyset pagination matches the exact query for interleaved owners", ctx do
second = insert(:user)
{:ok, user, _} = User.follow(ctx.user, second)
posts =
for index <- 1..9 do
author = Enum.at([ctx.followed, second, user], rem(index, 3))
{:ok, post} = CommonAPI.post(author, %{status: "post #{index}"})
post
end
[first | _] = posts
middle = Enum.at(posts, 4)
last = List.last(posts)
recipients = recipients(user)
opts = %{ctx.opts | user: user}
for bounds <- [
%{limit: 3},
%{max_id: middle.id, limit: 3},
%{since_id: first.id, limit: 3},
%{min_id: first.id, limit: 3},
%{min_id: middle.id, max_id: last.id, limit: 2},
%{min_id: first.id, order_asc: true, limit: 4}
] do
opts = Map.merge(opts, bounds)
expected = ActivityPub.fetch_activities(recipients, opts)
{activities, queries} = home(user, opts)
assert activities == expected
assert per_owner?(queries)
end
end
test "prunes muted, blocked and (for local_only) remote owners", ctx do
muted = insert(:user)
blocked = insert(:user)
remote = insert(:user, local: false)
{:ok, user, _} = User.follow(ctx.user, muted)
{:ok, user, _} = User.follow(user, blocked)
{:ok, user, _} = User.follow(user, remote)
{:ok, _} = User.mute(user, muted)
{:ok, _} = User.block(user, blocked)
for author <- [user, ctx.followed, muted, blocked] do
{:ok, _} = CommonAPI.post(author, %{status: "hi"})
end
insert(:note_activity, user: remote, local: false)
opts = Map.merge(ctx.opts, %{user: user, muting_user: user, blocking_user: user})
for {filters, expected_owners} <- [
{%{}, [user, ctx.followed, remote]},
{%{with_muted: true}, [user, ctx.followed, muted, remote]},
{%{local_only: true}, [user, ctx.followed]}
] do
opts = Map.merge(opts, filters)
{activities, owners} =
owners(fn -> ActivityPub.fetch_home_activities(recipients(user), opts) end)
assert Enum.sort(owners) == Enum.sort(Enum.map(expected_owners, & &1.ap_id))
assert activities == ActivityPub.fetch_activities(recipients(user), opts)
end
end
# A type list next to the owner's type makes PostgreSQL < 17 read every
# (owner, type) row and sort them instead of stopping after one page.
test "leaves the type to the owner in the per-owner query", ctx do
{:ok, post} = CommonAPI.post(ctx.followed, %{status: "hi"})
{activities, queries} = home(ctx.user, ctx.opts)
assert ids(activities) == [post.id]
lateral = Enum.find(queries, &String.contains?(&1, "LATERAL"))
refute lateral =~ ~r/->>'type' = ANY\(/
end
test "searches direct addressing only within a full page of owner posts", ctx do
{:ok, old_dm} =
CommonAPI.post(ctx.other, %{status: "@#{ctx.user.nickname} old", visibility: "direct"})
posts =
for index <- 1..4 do
{:ok, post} = CommonAPI.post(ctx.followed, %{status: "post #{index}"})
post
end
{:ok, new_dm} =
CommonAPI.post(ctx.other, %{status: "@#{ctx.user.nickname} new", visibility: "direct"})
recipients = recipients(ctx.user)
for bounds <- [
+ %{limit: 0},
%{limit: 2},
%{limit: 6},
%{max_id: new_dm.id, limit: 2},
%{min_id: old_dm.id, limit: 2},
%{min_id: hd(posts).id, limit: 2},
%{order_asc: true, limit: 2}
] do
opts = Map.merge(ctx.opts, bounds)
{activities, _queries} = home(ctx.user, opts)
assert activities == ActivityPub.fetch_activities(recipients, opts)
end
# a full page of owner posts bounds the search for direct addressing
{_activities, queries} = home(ctx.user, Map.put(ctx.opts, :limit, 2))
addressed = Enum.find(queries, &(&1 =~ ~r/&& a0\."recipients"/ and &1 =~ "LIMIT"))
assert addressed =~ ~r/a0\."id" > \$\d+/
end
test "keeps the overlap query for followed hashtags", ctx do
hashtag = insert(:hashtag, name: "tenshi")
{:ok, tagged} = CommonAPI.post(ctx.other, %{status: "maji #tenshi"})
{:ok, post} = CommonAPI.post(ctx.followed, %{status: "hi"})
{activities, queries} = home(ctx.user, Map.put(ctx.opts, :followed_hashtags, [hashtag.id]))
assert ids(activities) == [tagged.id, post.id]
refute per_owner?(queries)
end
test "keeps the overlap query without a list of types", ctx do
{:ok, post} = CommonAPI.post(ctx.followed, %{status: "hi"})
{activities, queries} = home(ctx.user, Map.delete(ctx.opts, :type))
assert ids(activities) == [post.id]
refute per_owner?(queries)
end
end
# The owner list is the first parameter of the per-owner query.
defp owners(fun) do
id = {__MODULE__, make_ref()}
:ok = :telemetry.attach(id, [:pleroma, :repo, :query], &__MODULE__.record_params/4, self())
try do
result = fun.()
assert_received {:lateral_params, [owners | _]}
{result, owners}
after
:telemetry.detach(id)
end
end
def record_params(_event, _measurements, %{query: query, params: params}, pid) do
if String.contains?(query, "LATERAL"), do: send(pid, {:lateral_params, params})
end
defp recipients(user), do: [user.ap_id | User.following(user)]
defp home(user, opts) do
queries(fn -> ActivityPub.fetch_home_activities(recipients(user), opts) end)
end
defp ids(activities), do: Enum.map(activities, & &1.id)
defp insert_gap(count, user) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
rows =
for _ <- 1..count,
do: %{
id: FlakeId.get(),
actor: user.ap_id,
recipients: [],
data: %{"type" => "Like"},
inserted_at: now,
updated_at: now
}
Repo.insert_all(Pleroma.Activity, rows)
end
defp queries(fun) do
id = {__MODULE__, make_ref()}
:ok = :telemetry.attach(id, [:pleroma, :repo, :query], &__MODULE__.record_query/4, self())
try do
result = fun.()
{result, drain_queries()}
after
:telemetry.detach(id)
end
end
def record_query(_event, _measurements, metadata, pid), do: send(pid, {:query, metadata.query})
defp drain_queries do
receive do
{:query, query} -> [query | drain_queries()]
after
0 -> []
end
end
defp per_owner?(queries), do: Enum.any?(queries, &String.contains?(&1, "LATERAL"))
defp fast?(queries), do: Enum.any?(queries, &String.contains?(&1, "home_timeline_candidates"))
defp fallback?(queries),
do:
Enum.any?(queries, &(String.contains?(&1, " && ") and not String.contains?(&1, "unnest(")))
end

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 2, 1:45 AM (13 h, 33 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1803494
Default Alt Text
(33 KB)

Event Timeline