Egregoros

Signal feed

Timeline

Post

Remote status

Context

35
@pernia @phnt I deleted remote activities that referenced objects that did not come from decayable (so that we didn't lose any history of interactions). I did not run anything else manually except a vacuum full maybe? I let pleroma decide on which objects to keep.
I also ran the deletions in batches of ~10k with a shell script to keep things moving and give me an indication of progress.

@phnt @pernia

#!/bin/bash
to_delete=$1
batch_size=$2
do_delete () {
	n=$1
	out=$(psql -U pleroma -d pleroma -p 5435 -c "delete from activities where id in (select id from activities a where a.inserted_at < '2026-01-01'::date and not local and split_part('data->>object', '/', 3) != 'decayable.ink' limit $n)")
	echo $out
}

i=$to_delete
while [[ $i -gt 0 ]]; do
	a=$(do_delete $batch_size);
	i=$((i-$batch_size))
	deleted=$(($to_delete - $i))
	pct=$(($deleted * 100 / $to_delete))
	echo $(date --iso-8601="seconds") $pct $a
done

you probably need to adjust how psql is called, and obviously the date and the domain. The query is a little ugly in there but here it is formatted a little better:

delete from activities where id in (
  select id from activities a 
  where 
  a.inserted_at < '2026-01-01'::date 
  and not local 
  and split_part(data->>'object', '/', 3) != 'decayable.ink'
  limit 10000
)

The compound select is necessary to do this in batches, which keeps postgres flushing the deletes constantly instead of aggregating everything up and do one biiiiiig delete. As a bonus the script eats the output and gives you progress readouts. It's okay to go a little over the total number of rows you want to delete (you can count(*) the inner select in order to get the exact amount).

@pernia @phnt what this query does is delete remote activities which reference remote objects, that's all.
So if you left an eggplant react on a post from poast, we delete our record of that interaction, because you're not a local user, and that post doesn't belong to one of our users.

@phnt @pwm

actually you're right. i think i found out what the actual difference is.

it seems mitra has a post table, where it stores fully normalized activities

CREATE TABLE post (
    id UUID PRIMARY KEY,
    author_id UUID NOT NULL REFERENCES actor_profile (id) ON DELETE CASCADE,
    title TEXT,
    content TEXT NOT NULL,
    content_source TEXT,
    language CHAR(3),
    conversation_id UUID, -- FK is added later
    in_reply_to_id UUID REFERENCES post (id) ON DELETE CASCADE,
    repost_of_id UUID REFERENCES post (id) ON DELETE CASCADE,
    repost_has_deprecated_ap_id BOOLEAN NOT NULL DEFAULT FALSE,
    group_id UUID REFERENCES actor_profile (id) ON DELETE CASCADE,
    visibility SMALLINT NOT NULL,
    is_sensitive BOOLEAN NOT NULL,
    is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
    reply_count INTEGER NOT NULL CHECK (reply_count >= 0) DEFAULT 0,
    reaction_count INTEGER NOT NULL CHECK (reaction_count >= 0) DEFAULT 0,
    repost_count INTEGER NOT NULL CHECK (repost_count >= 0) DEFAULT 0,
    url VARCHAR(2000),
    object_id VARCHAR(2000) UNIQUE,
    ipfs_cid VARCHAR(200),
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_at TIMESTAMP WITH TIME ZONE,
    UNIQUE (author_id, repost_of_id),
    CHECK ((conversation_id IS NULL) != (repost_of_id IS NULL))
);

see how there's no fuckass blob of jsonb in there? how post content is text and ID's are UUID's and urls are urls?

it ALSO however does keep the jsonb blobs in a separate table called activitypub_object

CREATE TABLE activitypub_object (
    object_id VARCHAR(2000) PRIMARY KEY,
    object_data JSONB NOT NULL,
    profile_id UUID UNIQUE REFERENCES actor_profile (id) ON DELETE CASCADE,
    post_id UUID UNIQUE REFERENCES post (id) ON DELETE CASCADE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);

so like wtf? why is pleroma so goddam fat?

look at how mitra stores "Likes":

reaction_count INTEGER NOT NULL CHECK (reaction_count >= 0) DEFAULT 0,

its just an integer. straight up. and its not even a "like", its a "reaction", so likes are really just emoji reactions, and you can see that in the mitra web interface, because when you click like, it shows a thumbs up emoji on the post.

there's another table for emoji_reactions in mitra that stores this. so you save even more data like that.

now, how does pleroma stores "likes"? run this query:

SELECT
    id,
    data->>'type' AS activity_type,
    jsonb_pretty(data) AS full_json
FROM
    activities
WHERE
    data->>'type' = 'Like'
LIMIT 5;

ITS A FULL FUCKASS PIECE OF JSON. A WHOLE FUCKING LOG OF SHIT. FOR A LIKE.

like, sure. i get to see who the like came from. it says who it was for. i get a link to the object as well. pleroma has far greater richness of data.

BUT IT DOES IT FOR EVERY LIKE. THATS LIKE HALF A KILOBYTE OF JSON PER LIKE.

MITRA DOESN'T WASTE EVEN A BYTE (or however much space an integer takes) on EVERY like in a post.

now count in reposts. fuck. no wonder mitra takes 100 times less space for the same data.

that also explains why nuking all those likes and boosts freed up like 80% of my db space. pleroma just loves storing worthless crap, and mitra is far more pragmatic about it.

i'm not well versed in AP, perhaps this is how the spec authors intended to store likes. in half a kilobyte of json. but well, i think this is a proper explanation as to why pleroma is so damn fat all the time and mitra is so hot and skinny.

paging @silverpill @lain @mint for validation and or to call me a dumbass nigger. maybe this has been obvious for a long time and phnt just didn't have the heart to tell me.

@pernia @mischievoustomato @pwm @phnt @kirby @p @lain @meso @graf You're right, mitra stores most data in a normalized form. Reactions are not just a number though, there is a separate table for them: https://codeberg.org/silverpill/mitra/src/commit/c2d3697c3fa0bf1ae41575504094ee340ce16c12/mitra_models/migrations/schema.sql#L256-L268

We also store some raw activities and objects, but they are pruned aggressively and don't take much space.

This may explain the difference in database sizes, I don't know enough about pleroma to say for sure.

@silverpill @mischievoustomato @pwm @kirby @p @lain @meso @graf @pernia Pleroma basically took the completely opposite extreme to what Mastodon did (normalizing everything into a very specific schema). The Pleroma schema is very simple and mostly stores raw AP Activities/Objects which causes the size difference per-post/reaction. What Mitra stores in a separate table (post content, visibility,...), Pleroma stores in a single jsonb column. reaction count, repeat count, like count, tags are all stored in the same jsonb blob along the original AP Object representation. There are some advantages to that, like not needing a join across different tables. jsonb is also slightly larger because it is a pre-parsed json representation.

The split activities/objects table schema also introduced more DB size as there are more indexes needed for it to work. It's too late to deeply optimize and normalize the schema now, maybe with the eventual Pleroma 3.0 some day in the likely distant future and an hours/days long data migration.
@pernia @mischievoustomato @pwm @kirby @p @lain @silverpill @meso @graf @pernia The whole point of the schema is that it is extremely flexible. You don't have to write large migrations to support features, deal with split normalized/non-normalized metadata schema that can change depending on when you support new features (pulling out previously unnormalized metadata into separate tables/columns resulting in expensive data migrations). You can even store data you don't yet understand, implement support for it later and that data will be usable from earlier.

But of course hindsight is 20/20 and the size of current instances and load was probably nowhere near what lain expected when that schema was decided. Honestly I wouldn't really change much of it, except the split Activities/Objects tables.
@phnt @graf @kirby @lain @meso @mischievoustomato @pernia @pernia @pwm @silverpill

> Just because the underlying structure is jsonb doesn't make the index based on it suddenly larger, it's all btrees and some GIS.

jsonb is not the issue.

The issue is using massive strings instead of comparatively tiny 64-bit integers. One of these is easier to index. It doesn't matter how you hash it, it doesn't matter how you organize it on disk: the pages are going to be larger, the *holes* are going to be larger, the index is going to take longer to build. There is no way around this: you have to put it *somewhere*.

The way Pleroma does things could be helped some by doing small tables with IDs; you encounter a URL, you upsert an entry, the foreign key is an int, your index is smaller, and then instead of paying the price during the index scan, you pay it during the join, and for the shape of the data in Pleroma (consider a "POST /inbox" and the actor URL that arrives and how many actor URLs you need in order to process that request), this works out much better. You can keep the entire raw jsonb object right where it is, you just add some FKs and index on those and you get a much smaller index, and this translates into less disk-seeking when Postgres does the scan of the index (seek distance walking a btree goes down if the index size goes down, seek distance walking *anything* goes down), inserts get way easier to validate (find the int in the btree versus find the string). The larger the size of the index, the more often you have to seek farther, the more pointer-chasing the filesystem has to do across inodes, the more pages Postgres has to read in. Seek cost is nonzero even on fancy top-of-the-line NVMe disks. There's no way around this problem.

Replies

9
@p @graf @kirby @lain @meso @mischievoustomato @pernia @pernia @pwm @silverpill Also to say the obvious, I don't think it is possible to get rid of the large string indexes fully. Even if you store the Activity/Object URL in a separate table, you still need an index on the string to avoid a full sequential table scan for that and then an index on the uuid/bigint for the Object the URL represents for the join. There are now two indexes instead of one and their job is to glue two tables together, while one still indexes the large string, when one index and one table does the same job. It doesn't make sense. And the Activities/Objects apid URL indexes are one of the largest indexes in the whole schema while also being very hot paths (the apid Object index is hit on every federation ingestion).

But the two activities_actor_* indexes which are also very large could benefit from using the native UUIDs instead of the URLs I think.
@phnt @graf @kirby @lain @meso @mischievoustomato @pernia @pernia @pwm @silverpill

> you still need an index on the string

Yes, you will, in order to look it up, but currently it needs to be done every time instead of sporadically. There is a critical difference that you're missing or that I'm not explaining properly.

You have an actor with an actor_id FK, it points at a table that just has (id, url) and acts as a lookup table. You have an index on that FK. You have activities, you have an actor_id on those activities, it's the same and points at the same table. (You use a trigger to maintain the table and do upserts as needed;

You never have to join on the URL in this case: you have an actor and want to get their activities, you have an activity and want to find the actor, you have a list of activities and you want to do a left join to tack actors and objects on, etc., and you're joining on a trivial int key that fits in a register and that a 4096-byte page (Postgres page size, filesystem pages, virtual memory pages, take your pick) can keep 512 of. In those cases, probably 90% of the queries you run, you join on the FK, and those are the overwhelming majority of cases: you're scanning a comparatively tiny index, activities and objects are the biggest tables in the DB even without their indexes, and you have several indexes that use URLs. I/O goes through the floor by comparison. You have exceptions, right, "POST /inbox" happens or you fetch a post and in those cases, you get the ID from the table, but those are the only cases where you have to touch the URL lookup table and you only have to do it once on one table rather than repeatedly across every table you're joining: even the cases where you have to look it up are faster. You insert the activity and the object and instead of the `data->'object'->>'id'`, you're checking an object_id FK.

The whole thing is several levels removed from the user (so none of the "oh, no, sequential IDs!" problems), and you can make it transparent in the code. So for the minor tweak in how URLs are stored, you end up saving a lot of disk space (which doesn't matter that much since even if it were bigger, I'd trade size for minimizing I/O, also every single goddamn time I have to look up the `\x` toggle but it beats turning the post into HTML or Markdown or something):

# select pg_size_pretty(pg_relation_size('objects')) as "objects", pg_size_pretty(pg_relation_size('objects_pkey')) as "pkey - int64", pg_size_pretty(pg_relation_size('objects_in_reply_to_index')) as "in_reply_to - string", pg_size_pretty(pg_relation_size('objects_unique_apid_index')) as "unique_apid_index - string";
-[ RECORD 1 ]--------------+--------
objects | 156 GB
pkey - int64 | 3810 MB
in_reply_to - string | 5367 MB
unique_apid_index - string | 17 GB

Each object has one pkey and one unique apid (so they're comparable), and the index size is 3.8GB versus 17GB. The in_reply_to index is sparse because most objects are not replies, but you can expect a proportional reduction. You get the size reduction because URLs are indexed once instead of multiple times across tables (and on a table where there are almost never any deletes and where there are zero updates) at the cost of an extra key, you keep the referential integrity, and joins are walking an index that is about 25% of the size and that's on both tables:

# select pg_size_pretty(pg_relation_size('activities')) as "activities", pg_size_pretty(pg_relation_size('activities_actor_index')) as "actor URL unsorted - string", pg_size_pretty(pg_relation_size('activities_create_objects_index')) as "Creates->object index - string", pg_size_pretty(pg_relation_size('activities_in_reply_to')) as "activity in_reply_to - string", pg_size_pretty(pg_relation_size('activities_unique_apid_index')) as "activity unique URL - string";
-[ RECORD 1 ]------------------+--------
activities | 270 GB
actor URL unsorted - string | 26 GB
Creates->object index - string | 19 GB
activity in_reply_to - string | 2760 MB
activity unique URL - string | 33 GB

> There are now two indexes instead of one and their job is to glue two tables together, while one still indexes the large string

There already *are* two indexes. Try out `select select state,pid,CURRENT_TIMESTAMP-query_start, datname, query from pg_stat_activity where state <> 'idle' order by CURRENT_TIMESTAMP-query_start DESC;`, find the queries that take a while (if no query is taking very long, check the nginx logs for slow requests and just do some of those actions; if you haven't been logging backend timing information, ).,

> one index and one table does the same job.

As explained above, it does a worse job as-is. The implementation we have is fairly straightforward and easy to understand; I don't think the solution I proposed is any harder to understand but it minimizes I/O and the bottleneck is I/O.

> It doesn't make sense.

If it's about computers and it *seems* like I'm suggesting something that makes no sense, we have a failure to communicate. :coolhandluke:

> And the Activities/Objects apid URL indexes are one of the largest indexes in the whole schema while also being very hot paths

This is my point, yes.
@p @mischievoustomato @pernia @pwm @kirby @lain @silverpill @meso @graf @pernia
>There is a critical difference that you're missing or that I'm not explaining properly.

We were talking mostly about the same thing. Your approach you described is similar to what I described with the activities_actor_* indexes that store URLs in the activities table instead of using the UUID already assigned to the actors, but also expanded to Objects. I agree that this approach would be better.

>You have exceptions, right, "POST /inbox" happens or you fetch a post and in those cases, you get the ID from the table, but those are the only cases where you have to touch the URL lookup table

This is the thing I was talking about where separating them doesn't make sense, besides that TLs also hit the Objects URL index due to how that currently works (when not cached), but that can be changed easily as described above. On a larger instance the ratio of POSTs to queries that would benefit from a separate (id, url) table is different and I was mostly thinking small as that's what I run.

I would do benchmarks when redesigning the schema anyway to test these ideas...
@phnt @graf @kirby @lain @meso @mischievoustomato @pernia @pernia @pwm @silverpill

> This is the thing I was talking about where separating them doesn't make sense

It's a one-time cost that is already being paid multiple times. Shrinking the index minimizes I/O (and memory usage, which means a bigger chunk of the index fits in memory and raises the likelihood that you will have it in cache). You're looking at the cost of the table without looking at the other costs that it eliminates. This is faster regardless of instance size.

> I would do benchmarks when redesigning the schema anyway to test these ideas...

Well, if I were doing it from the ground up, I'd do a lot of stuff differently but this is, I think, something that would be maybe a week of work on the existing codebase.
@p @mischievoustomato @pernia @pwm @kirby @lain @silverpill @meso @graf @pernia

>I think, something that would be maybe a week of work on the existing codebase.

Probably. The time it takes to update the codebase doesn't concern me much. What does is increasing the overall size of the schema (and by proxy adding fuel to the Pleroma = bloat thing that started this whole thread) and not taking down 1/2 of Pleroma instances down for a day or more to do a massive data migration (like the FlakeID migration). Even on my instance it would probably take a day when a repack takes ~10 hours.
@phnt @graf @kirby @lain @meso @mischievoustomato @pernia @pernia @pwm @silverpill

> What does is increasing the overall size of the schema

Add lines to the schema, reduces the size of the database; net might be lower lines because some of the URL-based indexes are complicated.

> adding fuel to the Pleroma = bloat thing

Anyone insisting that is an idiot that has never looked at Mastodon or Misskey. "Oh, I can have a snac instance that whatever something retarded." Different solution to a different problem. Doesn't matter much who thinks what proves what; people whine about bloat when something is slow and they don't care when it is fast and 99% of the time they don't know what bloat is and they don't understand the codebase. You can't cure a complaint based on ignorance by removing the thing they're actually complaining about: it's not connected to anything, it's just "some guy feels some way". You cannot solve that kind of person's problems because they are not connected to reality.

> down for a day or more to do a massive data migration

Oh, come on, we're in easy-mode stuff. The slow indexes are already there, `CREATE INDEX CONCURRENTLY` is present in basically every version of Postgres that Pleroma supports (I think `CONCURRENTLY` is 10+ and FSE was one of the last 9.x holdouts because it is hard to move a DB that is this big), you kick off a job that creates the index and then when it's done it drops the old ones, then you switch to using the new joins. Zero downtime past two restarts; if you wanna be nice, you can put something in the UI (fabricate an announcement) that keeps people posted on the status (hopefully some detail; even non-technical people on FSE tend to appreciate that detail is provided so that they can see that something is happening, even in cases where they don't understand the detail) so they know that there's something running that'll result in a couple of days of slowdown and then everything way faster.

> (like the FlakeID migration)

The five-stage commit was built specifically so things like the FlakeID migration would not result in downtime. There should not be a migration that causes that kind of issue. (If there is, it *really* should not say cutesy things about "buns" with a goddamn ASCII-art Clippy to the admin that is pulling his hair out because his instance is experiencing three days of downtime. This is one of the things that annoys me about the new FE, and god help the next person that puts "reticulating splines" into a goddamn loading screen because I will find him, I will tie him to his chair, and I will make him watch as I delete his Reddit account then *not* let him watch while I fuck his girlfriend.)