Scheduled posts: written now, published at their time

POST /api/v1/statuses with scheduled_at no longer posts at once: the post is
kept as asked (at least five minutes ahead; 300 waiting, 25 a day, as
Mastodon allows), its media kept from the janitor, and a PublishScheduled job
publishes it at its time as the persona. scheduled_statuses lists, moves and
drops them; a moved post's old job finds it not due. Idempotency-Key holds
for scheduling too.

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-05 02:55:17 +02:00
1 parent 73c362de96
commit cafd2e6db3
13 files changed
+418 -26

No files matched your search

+2 -2
View File
@@ -157,11 +157,11 @@ namespace PrivaPub.Domain.Media
}
}
//one pass: uploads left unattached for a day go, then the proxy cache is trimmed to its size, oldest first
//one pass: uploads left unattached for a day go (unless a scheduled post waits for them), then the proxy cache is trimmed to its size, oldest first
public async Task Sweep(CancellationToken token)
{
var cutoff = DateTime.UtcNow - UnattachedLifetime;
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.ScheduledStatusId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
await _media.Delete(stale);
TrimProxyCache();
}
@@ -0,0 +1,108 @@
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;
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) => new()
{
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
};
// 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();
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);
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;
}
}
}