Unique indexes, one username space, and entity maps warmed before use

Infrastructure/Data/Indexes runs at every start, after the migrations:
unique on Post/DmPost ObjectURI, ForeignAvatar ActorURI, the Follower
triple, RootToAvatar, RootUser UserName and ReservedName; plain indexes on
the lookups the services actually make (PublicKeyId, author and group
post listings, ParticipantsKey, the delivery queue).

Migration _001 runs first and removes the duplicates the races could
already have left (keeping the newest actor row, the oldest post, the
accepted follower), then fills ReservedName from every avatar and group.

ReservedName is one username space for personas, groups and the instance:
a name is reserved by an insert the unique index arbitrates, before the
avatar or group is saved, so two simultaneous sign-ups cannot both get it.
A short list of names (admin, support, abuse, postmaster, ...) is never
available.

EntityMaps.Warm touches every entity's collection one at a time before
anything else runs. MongoDB.Entities maps the Entity base class on first
touch, and two types mapped at once throw "An item with the same key has
already been added" and stay broken for the life of the process; the
parallel test run hit it, and the delivery worker racing a request could
have too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:01:08 +02:00
1 parent 0f85030744
commit d2f0c7a14e
10 files changed
+263 -3

No files matched your search

@@ -0,0 +1,59 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _001_deduplicate_before_unique_indexes : IMigration
{
public async Task UpgradeAsync()
{
var foreignAvatars = await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI != null).ExecuteAsync();
await DeleteAllBut(foreignAvatars.GroupBy(a => a.ActorURI).Select(g => g.OrderByDescending(a => a.UpdatedAt)), DeleteForeignAvatar);
var posts = await DB.Default.Find<Post>().Match(p => p.ObjectURI != null).ExecuteAsync();
await DeleteAllBut(posts.GroupBy(p => p.ObjectURI).Select(g => g.OrderBy(p => p.ID, StringComparer.Ordinal)), DeletePost);
var dmPosts = await DB.Default.Find<DmPost>().Match(p => p.ObjectURI != null).ExecuteAsync();
await DeleteAllBut(dmPosts.GroupBy(p => p.ObjectURI).Select(g => g.OrderBy(p => p.ID, StringComparer.Ordinal)), DeleteDmPost);
var followers = await DB.Default.Find<Follower>().ExecuteAsync();
await DeleteAllBut(followers.GroupBy(f => (f.LocalActorId, f.LocalActorKind, f.ActorURI))
.Select(g => g.OrderByDescending(f => f.IsAccepted).ThenBy(f => f.ID, StringComparer.Ordinal)), DeleteFollower);
var links = await DB.Default.Find<RootToAvatar>().ExecuteAsync();
await DeleteAllBut(links.GroupBy(r => (r.RootId, r.AvatarId)).Select(g => g.OrderBy(r => r.ID, StringComparer.Ordinal)), DeleteLink);
var taken = new HashSet<string>(StringComparer.Ordinal) { LocalActorService.InstanceUserName };
var reservations = new List<ReservedName>
{
new() { Name = LocalActorService.InstanceUserName, OwnerKind = LocalActorKind.Application }
};
foreach (var avatar in (await DB.Default.Find<Avatar>().ExecuteAsync()).OrderBy(a => a.ID, StringComparer.Ordinal))
if (!string.IsNullOrEmpty(avatar.UserName) && taken.Add(avatar.UserName.ToLowerInvariant()))
reservations.Add(new() { Name = avatar.UserName.ToLowerInvariant(), OwnerKind = LocalActorKind.Person, OwnerId = avatar.ID });
foreach (var group in (await DB.Default.Find<Group>().ExecuteAsync()).OrderBy(g => g.ID, StringComparer.Ordinal))
if (!string.IsNullOrEmpty(group.UserName) && taken.Add(group.UserName.ToLowerInvariant()))
reservations.Add(new() { Name = group.UserName.ToLowerInvariant(), OwnerKind = LocalActorKind.Group, OwnerId = group.ID });
await DB.Default.DeleteAsync<ReservedName>(_ => true);
await DB.Default.SaveAsync(reservations);
}
static async Task DeleteAllBut<T>(IEnumerable<IOrderedEnumerable<T>> groups, Func<T, Task> delete)
{
foreach (var group in groups)
foreach (var duplicate in group.Skip(1))
await delete(duplicate);
}
static Task DeleteForeignAvatar(ForeignAvatar a) => DB.Default.DeleteAsync<ForeignAvatar>(a.ID);
static Task DeletePost(Post p) => DB.Default.DeleteAsync<Post>(p.ID);
static Task DeleteDmPost(DmPost p) => DB.Default.DeleteAsync<DmPost>(p.ID);
static Task DeleteFollower(Follower f) => DB.Default.DeleteAsync<Follower>(f.ID);
static Task DeleteLink(RootToAvatar r) => DB.Default.DeleteAsync<RootToAvatar>(r.ID);
}
}