Media are reserved for one post, atomically

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 10:34:16 +02:00
1 parent bb680e8cb8
commit 65938bb2a0
4 files changed
+117 -14

No files matched your search

+21 -2
View File
@@ -7,6 +7,8 @@ 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
{
@@ -19,8 +21,9 @@ namespace PrivaPub.Domain.Statuses
public const int MaxWaiting = 300;
public const int MaxPerDay = 25;
public static StatusDraft Draft(ScheduledParams p) => new()
public static StatusDraft Draft(ScheduledParams p, string scheduledId = default) => new()
{
ScheduledStatusId = scheduledId,
Text = p.Text,
PlainText = true,
SpoilerText = p.SpoilerText,
@@ -66,6 +69,15 @@ namespace PrivaPub.Domain.Statuses
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);
@@ -93,10 +105,17 @@ namespace PrivaPub.Domain.Statuses
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), token);
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);
}