[backend/federation] Rework UserProfileMentionsResolver to no longer recursively resolve user profile mentions

This commit is contained in:
Laura Hausmann 2024-02-26 20:06:33 +01:00
parent 435632857b
commit 9a01fbecdd
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
10 changed files with 6329 additions and 134 deletions

View file

@ -1080,6 +1080,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
.HasDefaultValueSql("'{}'::public.notification_type_enum[]");
entity.Property(e => e.FFVisibility)
.HasDefaultValue(UserProfile.UserProfileFFVisibility.Public);
entity.Property(e => e.MentionsResolved).HasDefaultValue(false);
entity.HasOne(d => d.PinnedPage)
.WithOne(p => p.UserProfile)

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AddUserProfileMentionsResolvedColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "mentionsResolved",
table: "user_profile",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mentionsResolved",
table: "user_profile");
}
}
}

View file

@ -4577,6 +4577,12 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("mentions")
.HasDefaultValueSql("'[]'::jsonb");
b.Property<bool>("MentionsResolved")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("mentionsResolved");
b.Property<string>("ModerationNote")
.IsRequired()
.ValueGeneratedOnAdd()

View file

@ -176,6 +176,9 @@ public class UserProfile
[InverseProperty(nameof(Tables.User.UserProfile))]
public virtual User User { get; set; } = null!;
[Column("mentionsResolved")]
public bool MentionsResolved;
public class Field
{
[J("name")] public required string Name { get; set; }

View file

@ -0,0 +1,16 @@
namespace Iceshrimp.Backend.Core.Extensions;
public static class TaskExtensions
{
public static async Task SafeWaitAsync(this Task task, TimeSpan timeSpan)
{
try
{
await task.WaitAsync(TimeSpan.FromMilliseconds(500));
}
catch (TimeoutException)
{
// ignored
}
}
}

View file

@ -136,7 +136,9 @@ public class UserResolver(
}
}
public async Task<User?> ResolveAsyncLimited(string username, string? host, Func<bool> limitReached)
public async Task<User?> ResolveAsyncOrNull(string username, string? host)
{
try
{
var query = $"acct:{username}@{host}";
@ -156,16 +158,21 @@ public class UserResolver(
if (user != null)
return await GetUpdatedUser(user);
if (limitReached()) return null;
using (await KeyedLocker.LockAsync(uri))
{
// Pass the job on to userSvc, which will create the user
return await userSvc.CreateUserAsync(uri, acct);
}
}
catch
{
return null;
}
}
public async Task<User?> ResolveAsyncLimited(string uri, Func<bool> limitReached)
public async Task<User?> ResolveAsyncOrNull(string uri)
{
try
{
// First, let's see if we already know the user
var user = await userSvc.GetUserFromQueryAsync(uri);
@ -183,14 +190,17 @@ public class UserResolver(
if (user != null)
return await GetUpdatedUser(user);
if (limitReached()) return null;
using (await KeyedLocker.LockAsync(resolvedUri))
{
// Pass the job on to userSvc, which will create the user
return await userSvc.CreateUserAsync(resolvedUri, acct);
}
}
catch
{
return null;
}
}
public async Task<User> GetUpdatedUser(User user)
{

View file

@ -4,8 +4,6 @@ public class FollowupTaskService(IServiceScopeFactory serviceScopeFactory)
{
public bool IsBackgroundWorker { get; private set; }
public IServiceProvider? ServiceProvider { get; set; }
public Task ExecuteTask(string taskName, Func<IServiceProvider, Task> work)
{
return Task.Run(async () =>
@ -16,7 +14,6 @@ public class FollowupTaskService(IServiceScopeFactory serviceScopeFactory)
var provider = scope.ServiceProvider;
var instance = provider.GetRequiredService<FollowupTaskService>();
instance.IsBackgroundWorker = true;
instance.ServiceProvider = ServiceProvider ?? provider;
await work(provider);
}
catch (Exception e)

View file

@ -10,16 +10,9 @@ using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Services;
public class UserProfileMentionsResolver(
ActivityPub.UserResolver userResolver,
IOptions<Config.InstanceSection> config
)
public class UserProfileMentionsResolver(ActivityPub.UserResolver userResolver, IOptions<Config.InstanceSection> config)
{
private int _recursionLimit = 10;
public async Task<List<Note.MentionedUser>> ResolveMentions(
ASActor actor, string? host
)
public async Task<List<Note.MentionedUser>> ResolveMentions(ASActor actor, string? host)
{
var fields = actor.Attachments?.OfType<ASField>()
.Where(p => p is { Name: not null, Value: not null })
@ -43,33 +36,12 @@ public class UserProfileMentionsResolver(
var users = await mentionNodes
.DistinctBy(p => p.Acct)
.Select(async p =>
{
try
{
return await userResolver.ResolveAsyncLimited(p.Username, p.Host ?? host,
() => _recursionLimit-- <= 0);
}
catch
{
return null;
}
})
.Select(async p => await userResolver.ResolveAsyncOrNull(p.Username, p.Host ?? host))
.AwaitAllNoConcurrencyAsync();
users.AddRange(await userUris
.Distinct()
.Select(async p =>
{
try
{
return await userResolver.ResolveAsyncLimited(p, () => _recursionLimit-- <= 0);
}
catch
{
return null;
}
})
.Select(async p => await userResolver.ResolveAsyncOrNull(p))
.AwaitAllNoConcurrencyAsync());
return users.Where(p => p != null)
@ -85,9 +57,7 @@ public class UserProfileMentionsResolver(
.ToList();
}
public async Task<List<Note.MentionedUser>> ResolveMentions(
UserProfile.Field[]? fields, string? bio, string? host
)
public async Task<List<Note.MentionedUser>> ResolveMentions(UserProfile.Field[]? fields, string? bio, string? host)
{
if (fields is not { Length: > 0 } && bio == null) return [];
var input = (fields ?? [])
@ -101,18 +71,7 @@ public class UserProfileMentionsResolver(
var mentionNodes = EnumerateMentions(nodes);
var users = await mentionNodes
.DistinctBy(p => p.Acct)
.Select(async p =>
{
try
{
return await userResolver.ResolveAsyncLimited(p.Username, p.Host ?? host,
() => _recursionLimit-- <= 0);
}
catch
{
return null;
}
})
.Select(async p => await userResolver.ResolveAsyncOrNull(p.Username, p.Host ?? host))
.AwaitAllNoConcurrencyAsync();
return users.Where(p => p != null)

View file

@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Security.Cryptography;
using AsyncKeyedLock;
using EntityFramework.Exceptions.Common;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
@ -30,6 +31,12 @@ public class UserService(
EmojiService emojiSvc
)
{
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
{
o.PoolSize = 100;
o.PoolInitialFill = 5;
});
private (string Username, string? Host) AcctToTuple(string acct)
{
if (!acct.StartsWith("acct:")) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query");
@ -50,6 +57,16 @@ public class UserService(
return await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == query) ??
throw GracefulException.NotFound("User not found");
}
else if (query.StartsWith($"https://{instance.Value.WebDomain}/@"))
{
query = query[$"https://{instance.Value.WebDomain}/@".Length..];
if (query.Split('@').Length != 1)
return await GetUserFromQueryAsync($"acct:{query}");
return await db.Users.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Username == query.ToLower()) ??
throw GracefulException.NotFound("User not found");
}
else
{
return await db.Users
@ -165,7 +182,7 @@ public class UserService(
var processPendingDeletes = await ResolveAvatarAndBanner(user, actor);
await db.SaveChangesAsync();
await processPendingDeletes();
await UpdateProfileMentionsInBackground(user, actor);
await UpdateProfileMentions(user, actor);
return user;
}
catch (UniqueConstraintException)
@ -267,10 +284,12 @@ public class UserService(
user.UserProfile.UserHost = user.Host;
user.UserProfile.Url = actor.Url?.Link;
user.UserProfile.MentionsResolved = false;
db.Update(user);
await db.SaveChangesAsync();
await processPendingDeletes();
await UpdateProfileMentionsInBackground(user, actor);
await UpdateProfileMentions(user, actor);
return user;
}
@ -605,14 +624,19 @@ public class UserService(
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "Projectables")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")]
private async Task UpdateProfileMentionsInBackground(User user, ASActor? actor)
private async Task UpdateProfileMentions(User user, ASActor? actor)
{
if (followupTaskSvc.IsBackgroundWorker) return;
if (KeyedLocker.IsInUse($"profileMentions:{user.Id}")) return;
var task = followupTaskSvc.ExecuteTask("UpdateProfileMentionsInBackground", async provider =>
{
using (await KeyedLocker.LockAsync($"profileMentions:{user.Id}"))
{
var bgDbContext = provider.GetRequiredService<DatabaseContext>();
var bgMentionsResolver = (followupTaskSvc.ServiceProvider ?? provider)
.GetRequiredService<UserProfileMentionsResolver>();
var bgUser = await bgDbContext.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id);
var bgMentionsResolver = provider.GetRequiredService<UserProfileMentionsResolver>();
var userId = user.Id;
var bgUser = await bgDbContext.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == userId);
if (bgUser?.UserProfile == null) return;
if (actor != null)
@ -639,15 +663,16 @@ public class UserService(
else
{
bgUser.UserProfile.Mentions = await bgMentionsResolver.ResolveMentions(bgUser.UserProfile.Fields,
bgUser.UserProfile.Description,
bgUser.Host);
bgUser.UserProfile.Description, bgUser.Host);
}
bgUser.UserProfile.MentionsResolved = true;
bgDbContext.Update(bgUser.UserProfile);
await bgDbContext.SaveChangesAsync();
user = bgUser;
}
});
if (followupTaskSvc.IsBackgroundWorker)
await task;
await task.SafeWaitAsync(TimeSpan.FromMilliseconds(500));
}
}