An upload's owner checked that it was free, then attached it in a second step, so two posts asking for the same upload at once could both get it; a post now, or an edit, could take media a scheduled post held, which then failed when its time came (only logged) and was deleted; and a persona deleted or banned after scheduling a post still published it. Now a post claims its media in one conditional update before anything is written (its id is minted first), and a post that loses the race is refused with 422, the media it took put back. Scheduling reserves media the same way. Media held by a scheduled post are that post's alone, and its job publishes only for a persona still there whose root is neither deleted nor banned; otherwise what it held is trashed. MastodonScheduledStatusesTests: a post now and an edit can't take scheduled media, two racing posts never both get an upload, a gone persona's scheduled post never publishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
128 lines
5.6 KiB
C#
128 lines
5.6 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.Media;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Domain.Media;
|
|
|
|
namespace PrivaPub.Domain.Statuses
|
|
{
|
|
// Scheduled posts (Mastodon's scheduled_statuses): kept as asked, their media kept from the janitor, and published at
|
|
// their time by a PublishScheduled job as if the persona posted then. Mastodon's limits hold: at least five minutes
|
|
// ahead, 300 waiting, 25 on one day.
|
|
public static class ScheduledStatuses
|
|
{
|
|
public static readonly TimeSpan Lead = TimeSpan.FromMinutes(5);
|
|
public const int MaxWaiting = 300;
|
|
public const int MaxPerDay = 25;
|
|
|
|
public static StatusDraft Draft(ScheduledParams p, string scheduledId = default) => new()
|
|
{
|
|
ScheduledStatusId = scheduledId,
|
|
Text = p.Text,
|
|
PlainText = true,
|
|
SpoilerText = p.SpoilerText,
|
|
Sensitive = p.Sensitive,
|
|
Visibility = Visibility(p.Visibility),
|
|
InReplyTo = p.InReplyToId,
|
|
Language = p.Language,
|
|
MediaIds = p.MediaIds,
|
|
QuotedStatusId = p.QuotedStatusId,
|
|
QuotePolicy = p.QuotePolicy,
|
|
Poll = p.Poll == default ? default : new PollDraft(p.Poll.Options, p.Poll.ExpiresIn, p.Poll.Multiple, p.Poll.HideTotals)
|
|
};
|
|
|
|
public static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch
|
|
{
|
|
"unlisted" => PostVisibility.Unlisted,
|
|
"private" => PostVisibility.FollowersOnly,
|
|
"direct" => PostVisibility.Direct,
|
|
_ => PostVisibility.Public
|
|
};
|
|
|
|
// why the persona cannot schedule at this time, or default
|
|
public static async Task<string> Refusal(string avatarId, DateTime at, string except, CancellationToken token)
|
|
{
|
|
if (at < DateTime.UtcNow + Lead)
|
|
return "Scheduled at The scheduled date must be at least 5 minutes in the future";
|
|
if (await DB.Default.CountAsync<ScheduledStatus>(s => s.AvatarId == avatarId && s.ID != except, token) >= MaxWaiting)
|
|
return $"Scheduled at You have exceeded the limit of {MaxWaiting} scheduled statuses";
|
|
var day = at.Date;
|
|
var next = day.AddDays(1);
|
|
if (await DB.Default.CountAsync<ScheduledStatus>(s => s.AvatarId == avatarId && s.ID != except && s.ScheduledAt >= day && s.ScheduledAt < next, token) >= MaxPerDay)
|
|
return $"Scheduled at You have exceeded the limit of {MaxPerDay} scheduled statuses on that day";
|
|
return default;
|
|
}
|
|
|
|
// a job per scheduled time: a rescheduled post has a job for its new time, and the old one finds it not due
|
|
public static Job JobFor(ScheduledStatus scheduled, string host) => new()
|
|
{
|
|
Kind = JobKind.PublishScheduled,
|
|
Payload = scheduled.ID,
|
|
Host = host,
|
|
DedupeKey = $"scheduled|{scheduled.ID}|{scheduled.ScheduledAt.Ticks}",
|
|
RunAt = scheduled.ScheduledAt
|
|
};
|
|
|
|
// whether a persona may still publish: neither deleted nor its root deleted or banned
|
|
public static async Task<bool> MayPublish(string avatarId, CancellationToken token)
|
|
{
|
|
if (!await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token))
|
|
return false;
|
|
var rootIds = (await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == avatarId).ExecuteAsync(token)).Select(r => r.RootId).ToList();
|
|
return await DB.Default.Find<RootUser>().Match(u => rootIds.Contains(u.ID) && u.DeletedAt == null && !u.IsBanned).ExecuteAnyAsync(token);
|
|
}
|
|
|
|
// the post's media go back to being plain uploads (the janitor's after a day), or are attached to the post by now
|
|
public static Task Release(string scheduledId, CancellationToken token) =>
|
|
DB.Default.Update<MediaAttachment>().Match(m => m.ScheduledStatusId == scheduledId).Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token);
|
|
}
|
|
|
|
public class PublishScheduledJob : IJobHandler
|
|
{
|
|
readonly IServiceScopeFactory _scopes;
|
|
readonly ILogger<PublishScheduledJob> _logger;
|
|
|
|
public PublishScheduledJob(IServiceScopeFactory scopes, ILogger<PublishScheduledJob> logger)
|
|
{
|
|
_scopes = scopes;
|
|
_logger = logger;
|
|
}
|
|
|
|
public JobKind Kind => JobKind.PublishScheduled;
|
|
public int Concurrency => 2;
|
|
public int MaxAttempts => 3;
|
|
public int PerHostLimit => 2;
|
|
|
|
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
|
{
|
|
var scheduled = await DB.Default.Find<ScheduledStatus>().MatchID(job.Payload).ExecuteFirstAsync(token);
|
|
if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30))
|
|
return JobOutcome.Done;//dropped, or moved to a later time that has its own job
|
|
using var scope = _scopes.CreateScope();
|
|
// a persona deleted or banned since it was scheduled publishes nothing, and what it held is trashed
|
|
if (!await ScheduledStatuses.MayPublish(scheduled.AvatarId, token))
|
|
{
|
|
await scope.ServiceProvider.GetRequiredService<IMediaService>().Trash(m => m.ScheduledStatusId == scheduled.ID, "its author is gone", token);
|
|
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
|
|
return JobOutcome.Done;
|
|
}
|
|
var author = await scope.ServiceProvider.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, scheduled.AvatarId, token);
|
|
if (author != default)
|
|
{
|
|
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>().Publish(author, ScheduledStatuses.Draft(scheduled.Params, scheduled.ID), token);
|
|
if (!outcome.Ok)
|
|
_logger.LogWarning("Scheduled status {Id} of {Avatar} could not be published: {Error}", scheduled.ID, scheduled.AvatarId, outcome.Error);
|
|
}
|
|
await ScheduledStatuses.Release(scheduled.ID, token);
|
|
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
|
|
return JobOutcome.Done;
|
|
}
|
|
}
|
|
}
|