From 5d1e331589e165354aaf9e3373f6c7689f8d2eb8 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 23 Sep 2026 13:45:02 +0400 Subject: [PATCH] fix(retention): only delete objects that belong to an evicted thread Eviction took the object ids of every activity in a thread. A Like or Announce whose context differs from its object's then deleted a post in another, possibly pinned thread. Found on a copy of a real instance: 593 posts from threads with local activity in the first 2M objects. --- lib/pleroma/retention.ex | 17 ++++++++++++++--- test/pleroma/retention_test.exs | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/retention.ex b/lib/pleroma/retention.ex index abb5ba5d3..c25cbd19a 100644 --- a/lib/pleroma/retention.ex +++ b/lib/pleroma/retention.ex @@ -161,6 +161,7 @@ defmodule Pleroma.Retention do deadline |> evictable_contexts_query(Keyword.put(opts, :contexts, contexts)) |> select([a], %{ + context: fragment("? ->> 'context'", a.data), activity_ids: type(fragment("array_agg(?)", a.id), {:array, FlakeId.Ecto.CompatType}), object_ids: fragment("array_agg(associated_object_id(?))", a.data) }) @@ -321,15 +322,23 @@ defmodule Pleroma.Retention do end @doc """ - Deletes the given threads. Each entry carries the activity ids and object - ids collected per thread, so deletion uses primary key and ap_id indexes only. + Deletes the given threads. Each entry carries the context, activity ids and + object ids collected per thread, so deletion uses primary key and ap_id + indexes only. + + An object is only deleted if it belongs to one of the threads itself: a + Like or Announce may carry a context other than its object's, and that + object's own thread can be pinned. """ - @spec evict([%{activity_ids: [String.t()], object_ids: [String.t() | nil]}]) :: stats() + @spec evict([ + %{context: String.t(), activity_ids: [String.t()], object_ids: [String.t() | nil]} + ]) :: stats() def evict([]), do: @empty_stats def evict(threads) do activity_ids = Enum.flat_map(threads, & &1.activity_ids) object_ids = threads |> Enum.flat_map(& &1.object_ids) |> Enum.reject(&is_nil/1) + contexts = Enum.map(threads, & &1.context) {:ok, stats} = Repo.transaction( @@ -340,6 +349,7 @@ defmodule Pleroma.Retention do join: o in Object, on: o.id == ho.object_id, where: fragment("? ->> 'id'", o.data) in ^object_ids, + where: fragment("? ->> 'context'", o.data) in ^contexts, distinct: true, select: ho.hashtag_id ) @@ -348,6 +358,7 @@ defmodule Pleroma.Retention do {objects, deleted_ap_ids} = Object |> where([o], fragment("? ->> 'id'", o.data) in ^object_ids) + |> where([o], fragment("? ->> 'context'", o.data) in ^contexts) |> where( [o], fragment( diff --git a/test/pleroma/retention_test.exs b/test/pleroma/retention_test.exs index ccb40cb69..2e0c7d829 100644 --- a/test/pleroma/retention_test.exs +++ b/test/pleroma/retention_test.exs @@ -196,6 +196,27 @@ defmodule Pleroma.RetentionTest do assert Object.get_by_ap_id(mention.data["object"]) end + test "keeps a pinned post that an evicted thread's activity points at", %{ + old_date: old_date + } do + remote_user = insert(:user, local: false) + remote_user2 = insert(:user, local: false) + local_user = insert(:user) + + post = old_remote_post(remote_user, old_date) + {:ok, local_like} = CommonAPI.favorite(post.id, local_user) + make_old(local_like, old_date) + + # A remote like that arrived with a context of its own + {:ok, like} = CommonAPI.favorite(post.id, remote_user2) + other_context = "https://remote.example/contexts/#{Ecto.UUID.generate()}" + age(like, old_date, %{local: false, data: Map.put(like.data, "context", other_context)}) + + assert %{contexts: 1, objects: 0} = Retention.run() + assert Object.get_by_ap_id(post.data["object"]) + assert Activity.get_by_id(post.id) + 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)