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:
1 parent
bb680e8cb8
commit
65938bb2a0
4 files changed
+117
-14
No files matched your search
@@ -38,6 +38,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
public string ConversationId { get; init; }
|
||||
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> MediaIds { get; init; }
|
||||
// the scheduled post being published, whose media are reserved for it alone
|
||||
public string ScheduledStatusId { get; init; }
|
||||
public double? Latitude { get; init; }
|
||||
public double? Longitude { get; init; }
|
||||
public double? RangeKm { get; init; }
|
||||
@@ -109,7 +111,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
{
|
||||
if (TooLong(draft) is { } tooLong)
|
||||
return tooLong;
|
||||
var media = await Media(author, draft.MediaIds, default, token);
|
||||
var media = await Media(author, draft.MediaIds, default, draft.ScheduledStatusId, token);
|
||||
if (media == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0 && draft.Poll == default)
|
||||
@@ -225,6 +227,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.ID = (string)post.GenerateNewID();
|
||||
post.ObjectURI = author.PostUri(post.ID);
|
||||
post.Url = author.PostHtmlUrl(post.ID);
|
||||
// the media are this post's before anything is written: another post claiming them meanwhile wins, and this one is refused
|
||||
if (!await Claim(media, post.ID, draft.ScheduledStatusId, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public;
|
||||
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
|
||||
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
|
||||
@@ -270,7 +275,6 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.ActivityURI = create?["id"]?.GetValue<string>();
|
||||
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
await Attach(media, post.ID, token);
|
||||
if (parent != default && Counted.Reply(post))
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
await _fanout.Distribute(post, token);
|
||||
@@ -297,7 +301,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
if (TooLong(draft) is { } tooLong)
|
||||
return tooLong;
|
||||
var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, token);
|
||||
var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, default, token);
|
||||
if (draft.MediaIds != default && media == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List<MediaAttachment>()).Count == 0 && post.Media.Count == 0)
|
||||
@@ -318,8 +322,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
var rendered = plain ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
|
||||
if (media != default)
|
||||
{
|
||||
if (!await Claim(media, post.ID, default, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
post.Media = media.Select(ToPostMedia).ToList();
|
||||
await Attach(media, post.ID, token);
|
||||
// what the edit left out is no longer held by anything
|
||||
var kept = media.Select(m => m.ID).ToList();
|
||||
await _media.Trash(m => m.PostId == post.ID && !kept.Contains(m.ID), "edited out", token);
|
||||
@@ -688,7 +693,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
};
|
||||
}
|
||||
|
||||
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token)
|
||||
// the author's own uploads, not yet another post's (nor held by another scheduled post), nor trashed, nor pictures
|
||||
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, string scheduledId, CancellationToken token)
|
||||
{
|
||||
if (ids == default || ids.Count == 0)
|
||||
return new List<MediaAttachment>();
|
||||
@@ -697,17 +703,28 @@ namespace PrivaPub.Domain.Statuses
|
||||
var wanted = ids.Distinct().ToList();
|
||||
var found = await DB.Default.Find<MediaAttachment>()
|
||||
.Match(m => wanted.Contains(m.ID) && m.OwnerAvatarId == author.Id && (m.PostId == null || m.PostId == postId)
|
||||
&& m.TrashedAt == null && m.ProfileOfAvatarId == null)
|
||||
&& (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId) && m.TrashedAt == null && m.ProfileOfAvatarId == null)
|
||||
.ExecuteAsync(token);
|
||||
return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default;
|
||||
}
|
||||
|
||||
static async Task Attach(IEnumerable<MediaAttachment> media, string postId, CancellationToken token)
|
||||
// attaches the media in one conditional update, true when all of them are the post's; when another post took one
|
||||
// meanwhile, the ones this call took go back as they were
|
||||
static async Task<bool> Claim(IReadOnlyList<MediaAttachment> media, string postId, string scheduledId, CancellationToken token)
|
||||
{
|
||||
var ids = media.Select(m => m.ID).ToList();
|
||||
if (ids.Count > 0)
|
||||
await DB.Default.Update<MediaAttachment>().Match(m => ids.Contains(m.ID))
|
||||
.Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
if (ids.Count == 0)
|
||||
return true;
|
||||
var claimed = await DB.Default.Update<MediaAttachment>()
|
||||
.Match(m => ids.Contains(m.ID) && (m.PostId == null || m.PostId == postId) && m.TrashedAt == null
|
||||
&& (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId))
|
||||
.Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
if (claimed.MatchedCount == ids.Count)
|
||||
return true;
|
||||
var taken = media.Where(m => m.PostId == null).Select(m => m.ID).ToList();
|
||||
await DB.Default.Update<MediaAttachment>().Match(m => taken.Contains(m.ID) && m.PostId == postId)
|
||||
.Modify(m => m.PostId, null).Modify(m => m.AttachedAt, null).ExecuteAsync(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
PostMedia ToPostMedia(MediaAttachment attachment) => new()
|
||||
|
||||
Reference in new issue
Block a user