fix(retention): keep threads whose post a local user quoted

A quote has a context of its own, so the quoted remote thread counted as
untouched and was evicted, breaking the local quote. On lain.com 45 of
134 quoted posts went this way. The objects_quote_url index keeps the
lookup at one probe per post.
This commit is contained in:
Lain Soykaf 2026-09-25 08:50:41 +04:00
commit e93f05a79f
No known key found for this signature in database
5 changed files with 53 additions and 5 deletions

View file

@ -1 +1 @@
Add a bounded retention mode (`config :pleroma, :retention`): a cron worker walks old activities in id order and evicts remote threads nobody local interacted with, in bounded batches, so the database plateaus instead of growing forever. Adds a `retention_cursors` table. `prune_objects --keep-threads` now also keeps threads that are addressed to a local user, still have a notification for one, or contain a reported post.
Add a bounded retention mode (`config :pleroma, :retention`): a cron worker walks old activities in id order and evicts remote threads nobody local interacted with, in bounded batches, so the database plateaus instead of growing forever. Adds a `retention_cursors` table. `prune_objects --keep-threads` now also keeps threads that are addressed to a local user, still have a notification for one, contain a post quoted by a local post, or contain a reported post.

View file

@ -1189,10 +1189,7 @@ Control favicons for instances.
Bounded retention of remote content. Local posts are your data; remote posts are a cache of somebody else's, and this treats them that way. When enabled, a cron worker (`Pleroma.Workers.Cron.RetentionWorker`, hourly by default) evicts a batch of remote threads that nobody local has interacted with and that have been quiet for longer than `remote_post_retention_days` (see [`:instance`](#instance)). The database then plateaus instead of growing without limit.
A thread is never evicted if it contains a local post, reply, favourite, repeat or reaction, a post bookmarked by a local user, a post addressed to a local user (direct messages and mentions), a post a local user still has a notification for, or a post that was reported. Relationship activities (follows, blocks) and chat messages are never touched. Evicted posts are refetched on demand when somebody opens them again, as with `prune_objects`.
!!! warning
A local quote post does not pin the post it quotes, since quotes do not share the quoted thread's context. Old quoted remote posts can be evicted and will be refetched when the quote is viewed.
A thread is never evicted if it contains a local post, reply, favourite, repeat or reaction, a post bookmarked by a local user, a post addressed to a local user (direct messages and mentions), a post quoted by a local post, a post a local user still has a notification for, or a post that was reported. Relationship activities (follows, blocks) and chat messages are never touched. Evicted posts are refetched on demand when somebody opens them again, as with `prune_objects`.
* `enabled`: Run the worker. Defaults to `false`.
* `max_objects`: Optional watermark. When the `objects` table is estimated to hold more rows than this, the oldest unpinned remote threads that have been quiet for more than a day are evicted, whatever `remote_post_retention_days` says. Defaults to `nil` (age only).

View file

@ -18,6 +18,7 @@ defmodule Pleroma.Retention do
users all carry the thread's context),
* is bookmarked by a local user,
* is addressed to a local user (direct messages and mentions),
* is a post quoted by a local post,
* still has a notification for a local user,
* is a report (Flag),
@ -245,6 +246,18 @@ defmodule Pleroma.Retention do
^local_prefix()
)
)
# A local quote has a context of its own, so look up quotes of the posts
# here; the objects_quote_url index is on the jsonb value.
|> having(
[a],
not fragment(
"bool_or(? ->> 'type' = 'Create' AND EXISTS (SELECT 1 FROM objects q WHERE q.data -> 'quoteUrl' = to_jsonb(associated_object_id(?)) AND left(q.data ->> 'actor', ?) = ?))",
a.data,
a.data,
^local_prefix_length(),
^local_prefix()
)
)
|> maybe_keep_non_public(Keyword.get(opts, :keep_non_public, false) == true)
end

View file

@ -200,6 +200,29 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do
refute Object.get_by_ap_id(old_remote_post2_id)
end
test "with the --keep-threads option it keeps old threads a local user quoted", %{
old_insert_date: old_insert_date
} do
remote_user = insert(:user, local: false)
local_user = insert(:user)
{:ok, quoted} = CommonAPI.post(remote_user, %{status: "quote me"})
quoted
|> Ecto.Changeset.change(%{local: false, updated_at: old_insert_date})
|> Repo.update!()
{:ok, quote} = CommonAPI.post(local_user, %{status: "look", quote_id: quoted.id})
quote
|> Ecto.Changeset.change(%{updated_at: old_insert_date})
|> Repo.update!()
Mix.Tasks.Pleroma.Database.run(["prune_objects", "--keep-threads"])
assert Object.get_by_ap_id(quoted.data["object"])
end
test "with the --keep-threads option it keeps old threads addressed to a local user", %{
old_insert_date: old_insert_date
} do

View file

@ -244,6 +244,21 @@ defmodule Pleroma.RetentionTest do
assert Object.get_by_ap_id(post.data["object"])
end
test "keeps an old remote thread whose post a local user quoted", %{old_date: old_date} do
remote_user = insert(:user, local: false)
local_user = insert(:user)
quoted = old_remote_post(remote_user, old_date)
{:ok, quote} = CommonAPI.post(local_user, %{status: "look at this", quote_id: quoted.id})
make_old(quote, old_date)
# The quote has a context of its own and does not pin the quoted thread.
refute quote.data["context"] == quoted.data["context"]
assert %{contexts: 0} = Retention.run()
assert Object.get_by_ap_id(quoted.data["object"])
end
test "keeps an old remote post that was reported", %{old_date: old_date} do
remote_user = insert(:user, local: false)
local_user = insert(:user)