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

@@ -116,9 +116,21 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked };
await DB.Default.SaveAsync(scheduled, token);
scheduled.ID = (string)scheduled.GenerateNewID();
// reserved in one conditional update: a post or another scheduled post taking one meanwhile wins
if (mediaIds.Count > 0)
await DB.Default.Update<Models.Media.MediaAttachment>().Match(m => mediaIds.Contains(m.ID)).Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token);
{
var reserved = await DB.Default.Update<Models.Media.MediaAttachment>()
.Match(m => mediaIds.Contains(m.ID) && m.PostId == null && m.ScheduledStatusId == null && m.TrashedAt == null)
.Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token);
if (reserved.MatchedCount != mediaIds.Count)
{
await DB.Default.Update<Models.Media.MediaAttachment>().Match(m => m.ScheduledStatusId == scheduled.ID)
.Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token);
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
}
}
await DB.Default.SaveAsync(scheduled, token);
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
if (cacheKey != default)
_cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1));