P7: thread backfill reads conversation containers and asks with If-None-Match
Build / Build (push) Failing after 7m52s

The FetchReplies job reads a thread's FEP-171b contextHistory before its FEP-7888 context: the posts each Add (or
Forte's plain Create) brought in, read from their own servers. A thread collection read whole keeps its ETag when the
document itself changes with every post (it counts them, or holds them all with no further page); the next read sends
it as If-None-Match and a 304 ends the job. The ETag is kept as sent, since NodeBB's has no quotes and the typed header
drops it. A context naming a post we hold (Forte's first post) is not fetched.

Checked in the pasture: Mastodon 4.7.3 answers the second read 304; NodeBB 4.16's unquoted ETag is kept (23/23 in its
scenario); a Forte thread completes through its replies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 21:33:49 +02:00
1 parent fb4949b511
commit 62189bf11d
10 files changed
+268 -35

No files matched your search

+5 -3
View File
@@ -394,9 +394,11 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB. redirects and read at most 1 MB.
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread, - **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
its replies are read from their servers, at most once an hour: the thread's `context` collection (FEP-7888) if it has its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
one, else its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only `contextHistory`: the posts its owner's `Add`s brought in) or its `context` collection (FEP-7888) if it has one, else
public and unlisted replies are kept, each fetched from its own origin. its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only public and
unlisted replies are kept, each fetched from its own origin. A thread collection read whole, which counts its posts or
holds them all, is asked again with `If-None-Match` and its last ETag; a 304 ends the read.
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like - **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub` and the reporter Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub` and the reporter
`/peasants/privapub_reports`) are the exception, since their keys are needed first. `/peasants/privapub_reports`) are the exception, since their keys are needed first.
@@ -216,6 +216,103 @@ namespace PrivaPub.Tests.Federation
Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature))); Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature)));
} }
[Fact]
public async Task A_conversation_container_is_read_before_the_context_and_only_its_added_posts_taken()
{
var owner = new RemoteActor(_harness.Peer, "owner");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(owner, "<p>a conversation</p>");
var history = NewId(owner, "conversations");
root["contextHistory"] = history;
root["context"] = NewId(owner, "never");
Served(root);
var embedded = Served(Reply(answerer, "added whole", root));
var referenced = Served(Reply(owner, "added by reference", embedded));
var liked = Served(Reply(answerer, "only liked", root));
var target = new JsonObject { ["type"] = "OrderedCollection", ["id"] = history, ["attributedTo"] = owner.Id };
JsonObject Add(JsonNode activity) => new()
{
["id"] = NewId(owner, "activities"), ["type"] = "Add", ["actor"] = owner.Id, ["object"] = activity, ["target"] = target.DeepClone()
};
var create = Served(Create(owner, referenced));
create["object"] = IdOf(referenced);
Served(create);
var byReference = Served(Add(IdOf(create)));
Served(new JsonObject
{
["id"] = history, ["type"] = "OrderedCollection", ["attributedTo"] = owner.Id, ["collectionOf"] = "Activity",
["orderedItems"] = new JsonArray(Create(owner, root), Add(Create(answerer, embedded)), IdOf(byReference), Add(Activity(answerer, "Like", JsonValue.Create(IdOf(liked)))))
});
var held = await Held(root);
Assert.Equal(1, await RunReplies(held));
var stored = await Stored(embedded, referenced, liked);
Assert.Equal(new[] { IdOf(embedded), IdOf(referenced) }.Order(), stored.Keys.Order());
Assert.Equal(held.ID, stored[IdOf(embedded)].AnsweringToPostId);
Assert.Equal(stored[IdOf(embedded)].ID, stored[IdOf(referenced)].AnsweringToPostId);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["context"]!.GetValue<string>()).AbsolutePath);
}
[Fact]
public async Task An_unchanged_context_is_not_read_again_while_it_counts_the_thread()
{
var token = TestContext.Current.CancellationToken;
var poster = new RemoteActor(_harness.Peer, "poster");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(poster, "<p>a topic</p>");
var context = NewId(poster, "topics");
var path = new Uri(context).AbsolutePath;
root["context"] = context;
Served(root);
var first = Served(Reply(answerer, "first", root));
var later = Served(Reply(poster, "later", root));
// NodeBB's: every post inline, counted, and an ETag without quotes
JsonObject Thread(params JsonObject[] notes) => new()
{
["id"] = context, ["type"] = "OrderedCollection", ["totalItems"] = notes.Length,
["orderedItems"] = new JsonArray(notes.Select(n => (JsonNode)IdOf(n)).ToArray())
};
_harness.Peer.ServeTagged(path, Thread(root, first).ToJsonString(), "4f930186eb");
var held = await Held(root);
var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue);
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal("4f930186eb", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
// the same version: a 304, and nothing read
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "4f930186eb");
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal("4f930186eb", _harness.Peer.Requests.Last(r => r.Path == path).Headers["If-None-Match"]);
Assert.Single(await Stored(first, later));
// a new one
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "5a0b");
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal(2, (await Stored(first, later)).Count);
Assert.Equal("5a0b", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
}
[Fact]
public async Task A_paged_contexts_etag_is_not_kept_since_its_first_page_hides_what_is_added_later()
{
var token = TestContext.Current.CancellationToken;
var poster = new RemoteActor(_harness.Peer, "poster");
var root = PublicNote(poster, "<p>a long thread</p>");
var context = NewId(poster, "contexts");
root["context"] = context;
Served(root);
var answer = Served(Reply(poster, "on the second page", root));
_harness.Peer.ServeTagged(new Uri(context).AbsolutePath, new JsonObject
{
["id"] = context, ["type"] = "Collection",
["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(root)), ["next"] = context + "/2" }
}.ToJsonString(), "W/\"first\"");
Served(new JsonObject { ["id"] = context + "/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(answer)) });
var held = await Held(root);
await new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue).Handle(RepliesJobHandler.For(held, 1), token);
Assert.Single(await Stored(answer));
Assert.False(await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context && c.ETag != null).ExecuteAnyAsync(token));
}
[Fact] [Fact]
public async Task A_threads_context_collection_brings_every_reply_at_once() public async Task A_threads_context_collection_brings_every_reply_at_once()
{ {
+17
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Tests.Support
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new(); readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new(); readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new(); readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
readonly ConcurrentDictionary<string, string> _etags = new();
public int Port { get; } public int Port { get; }
public string A => $"http://127.0.0.1:{Port}"; public string A => $"http://127.0.0.1:{Port}";
@@ -64,6 +65,15 @@ namespace PrivaPub.Tests.Support
context.Response.StatusCode = StatusCodes.Status404NotFound; context.Response.StatusCode = StatusCodes.Status404NotFound;
return; return;
} }
if (peer._etags.TryGetValue(key, out var etag))
{
if (context.Request.Headers.IfNoneMatch.ToString() == etag)
{
context.Response.StatusCode = StatusCodes.Status304NotModified;
return;
}
context.Response.Headers.ETag = etag;
}
context.Response.ContentType = document.ContentType; context.Response.ContentType = document.ContentType;
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B)); await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
}); });
@@ -74,6 +84,13 @@ namespace PrivaPub.Tests.Support
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json"); public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
// a document with an ETag: answered 304 to a request that names it in If-None-Match
public void ServeTagged(string path, string json, string etag)
{
_documents[path] = (json, "application/activity+json");
_etags[path] = etag;
}
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType); public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType); public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
@@ -17,6 +17,8 @@ namespace PrivaPub.Federation.Actors
public interface IRemoteActorService public interface IRemoteActorService
{ {
Task<FetchedJson> FetchObject(string uri, CancellationToken token); Task<FetchedJson> FetchObject(string uri, CancellationToken token);
// the same, or NotModified while the version `etag` names is still current
Task<FetchedJson> FetchObject(string uri, string etag, CancellationToken token) => FetchObject(uri, token);
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
bool KeyTemporarilyUnavailable(string keyId) => false; bool KeyTemporarilyUnavailable(string keyId) => false;
@@ -50,13 +52,17 @@ namespace PrivaPub.Federation.Actors
_options = options; _options = options;
} }
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token) public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => FetchObject(uri, default, token);
public async Task<FetchedJson> FetchObject(string uri, string etag, CancellationToken token)
{ {
using var scope = HttpScope.Default("object"); using var scope = HttpScope.Default("object");
var signer = await _localActors.GetInstanceActor(token); var signer = await _localActors.GetInstanceActor(token);
var fetched = await Get(uri, signer, token); var fetched = string.IsNullOrEmpty(etag)
if (fetched == default) ? await Get(uri, signer, token)
return default; : await _http.GetJsonIfChanged(uri, Accept, etag, request => HttpSignatures.Sign(request, signer, body: null), token);
if (fetched == default || fetched.NotModified)
return fetched;
var id = Text(fetched.Root, "id"); var id = Text(fetched.Root, "id");
if (Origin.IsDocumentAt(id, fetched.FinalUri)) if (Origin.IsDocumentAt(id, fetched.FinalUri))
+74 -20
View File
@@ -20,9 +20,11 @@ namespace PrivaPub.Federation.Inbox
public sealed record RepliesPayload(string PostId, int Depth); public sealed record RepliesPayload(string PostId, int Depth);
// What was said under a remote post on other servers, read when a persona opens its thread, so the thread shows more // What was said under a remote post on other servers, read when a persona opens its thread, so the thread shows more
// than what happened to reach us. The thread's own collection comes first (FEP-7888's `context`, which Mastodon 4.5+ // than what happened to reach us. The thread's own collections come first: its conversation container (FEP-171b's
// serves with every reply at any depth); otherwise the post's `replies` (PeerTube's `comments`), and the replies' // `contextHistory`, every activity its owner added to the thread), then its posts (FEP-7888's `context`, which
// own, two levels down. Read with the instance actor's signature, never a persona's, a few pages and at most // Mastodon 4.5+, Mitra and NodeBB serve with every reply at any depth), sent the ETag of the last whole read so an
// unchanged thread costs one 304; otherwise the post's `replies` (PeerTube's `comments`, Forte's whole thread), and the
// replies' own, two levels down. Read with the instance actor's signature, never a persona's, a few pages and at most
// MaxItems posts a job; each one is fetched from its own server and stored like any post fetched for a thread // MaxItems posts a job; each one is fetched from its own server and stored like any post fetched for a thread
// (RemotePosts.StoreContext: public and unlisted only, its author checked against its origin). // (RemotePosts.StoreContext: public and unlisted only, its author checked against its origin).
public class RepliesJobHandler : IJobHandler public class RepliesJobHandler : IJobHandler
@@ -74,14 +76,35 @@ namespace PrivaPub.Federation.Inbox
if (await Document(post, token) is not { } document) if (await Document(post, token) is not { } document)
return JobOutcome.Done; return JobOutcome.Done;
if (await Collection(document["context"], token) is { } thread) foreach (var name in new[] { "contextHistory", "context" })
{ {
await StoreAll(thread, token); var uri = Id(document[name]);
// Forte's `context` is the thread's first post
if (uri != default && await _dbEntities.Posts.Match(p => p.ObjectURI == uri).ExecuteAnyAsync(token))
continue;
var known = uri == default ? default : await DB.Default.Find<ThreadCollection>().Match(c => c.URI == uri).ExecuteFirstAsync(token);
var (thread, etag, unchanged) = await Collection(document[name], known?.ETag, token);
if (unchanged)
{
await DB.Default.Update<ThreadCollection>().MatchID(known.ID).Modify(c => c.ReadAt, DateTime.UtcNow).ExecuteAsync(token);
return JobOutcome.Done; return JobOutcome.Done;
} }
if (await Collection(document["replies"] ?? document["comments"], token) is not { } replies) if (thread == default)
continue;
var (_, read) = await StoreAll(thread, name == "contextHistory", token);
var whole = read && Counted(thread);
if (uri != default && (known != default || whole && etag != default))
await DB.Default.Update<ThreadCollection>()
.Match(c => c.URI == uri)
.Modify(c => c.ETag, whole ? etag : default)
.Modify(c => c.ReadAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
return JobOutcome.Done; return JobOutcome.Done;
var answers = await StoreAll(replies, token); }
if ((await Collection(document["replies"] ?? document["comments"], default, token)).Collection is not { } replies)
return JobOutcome.Done;
var (answers, _) = await StoreAll(replies, false, token);
if (payload.Depth < MaxDepth) if (payload.Depth < MaxDepth)
await _queue.EnqueueMany(answers.Where(a => a.IsFederatedCopy && a.AnsweringToPostId == post.ID).Take(MaxBranches) await _queue.EnqueueMany(answers.Where(a => a.IsFederatedCopy && a.AnsweringToPostId == post.ID).Take(MaxBranches)
.Select(a => For(a, payload.Depth + 1)), token); .Select(a => For(a, payload.Depth + 1)), token);
@@ -100,35 +123,41 @@ namespace PrivaPub.Federation.Inbox
return fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()) as JsonObject; return fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()) as JsonObject;
} }
// a collection as its server serves it now: what a post carries is read again by its id, since an embedded first // a collection as its server serves it now, and its ETag: what a post carries is read again by its id, since an
// page is as old as the post; a value that is no web address (Pleroma's `context` is a tag: URI), or no collection, // embedded first page is as old as the post; a value that is no web address (Pleroma's `context` is a tag: URI), or no
// gives nothing // collection, gives nothing, and one whose server still has the version `etag` names is unchanged
async Task<JsonObject> Collection(JsonNode node, CancellationToken token) async Task<(JsonObject Collection, string ETag, bool Unchanged)> Collection(JsonNode node, string etag, CancellationToken token)
{ {
var uri = Id(node); var uri = Id(node);
string served = default;
if (uri != default && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp)) if (uri != default && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp))
{ {
using var scope = HttpScope.For("replies"); using var scope = HttpScope.For("replies");
using var fetched = await _remoteActors.FetchObject(uri, token); using var fetched = await _remoteActors.FetchObject(uri, etag, token);
if (fetched?.NotModified == true)
return (default, etag, true);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
served = fetched?.ETag;
} }
return node is JsonObject collection && Value(collection, "type") is { } type && CollectionTypes.Contains(type) ? collection : default; return (node is JsonObject collection && Value(collection, "type") is { } type && CollectionTypes.Contains(type) ? collection : default, served, false);
} }
// every post the collection lists, stored when we lack it: its pages in order, at most MaxPages and MaxItems // every post the collection lists, stored when we lack it: its pages in order, at most MaxPages and MaxItems; whether
async Task<List<PostEntity>> StoreAll(JsonObject collection, CancellationToken token) // it was read to its end. A conversation container's items are its owner's Adds of the thread's activities.
async Task<(List<PostEntity> Posts, bool Whole)> StoreAll(JsonObject collection, bool activities, CancellationToken token)
{ {
var posts = new List<PostEntity>(); var posts = new List<PostEntity>();
var seen = 0; var seen = 0;
var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : await Page(collection["first"], token); var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : await Page(collection["first"], token);
for (var pages = 0; page != default && pages < MaxPages && seen < MaxItems; pages++) for (var pages = 0; page != default; pages++)
{ {
if (pages == MaxPages)
return (posts, false);
foreach (var item in Items(page)) foreach (var item in Items(page))
{ {
if (++seen > MaxItems) if (++seen > MaxItems)
break; return (posts, false);
// a context may list the activities that made the thread (FEP-f228) rather than its posts var id = activities ? await Added(item, token) : PostId(item);
var id = Value(item, "type") is "Create" or "Update" ? Id(item["object"]) : Id(item);
if (id == default) if (id == default)
continue; continue;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token) var post = await _dbEntities.Posts.Match(p => p.ObjectURI == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
@@ -138,7 +167,32 @@ namespace PrivaPub.Federation.Inbox
} }
page = page["next"] is { } next ? await Page(next, token) : default; page = page["next"] is { } next ? await Page(next, token) : default;
} }
return posts; return (posts, true);
}
// whether the document itself changes with every post added to the thread, so its ETag is the thread's: it counts
// them (NodeBB), or holds every one with no further page (a short Mastodon thread); a long Mastodon thread's first
// page stays as it was while its last one grows
static bool Counted(JsonObject collection)
{
if (collection["totalItems"] is JsonValue)
return true;
var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : collection["first"] as JsonObject;
return page != default && (page["orderedItems"] is JsonArray || page["items"] is JsonArray) && page["next"] == default;
}
// a context may list the activities that made the thread (FEP-f228) rather than its posts
static string PostId(JsonNode item) => Value(item, "type") is "Create" or "Update" ? Id(item["object"]) : Id(item);
// the post an activity in a conversation container brought into the thread, added by its owner or (Forte's) not: a
// Create's or an Update's object; a like, a removal or anything else adds no post. Only its id is taken, and the post
// is read from its own server.
async Task<string> Added(JsonNode item, CancellationToken token)
{
var activity = await Page(item, token);
if (activity != default && Value(activity, "type") == "Add")
activity = await Page(activity["object"], token);
return activity != default && Value(activity, "type") is "Create" or "Update" ? Id(activity["object"]) : default;
} }
async Task<JsonObject> Page(JsonNode node, CancellationToken token) async Task<JsonObject> Page(JsonNode node, CancellationToken token)
+3
View File
@@ -15,6 +15,7 @@ namespace PrivaPub.Infrastructure.Data
public static class Indexes public static class Indexes
{ {
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90); public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
public static readonly TimeSpan ThreadCollectionLifetime = TimeSpan.FromDays(30);
public static async Task Create(CancellationToken token = default) public static async Task Create(CancellationToken token = default)
{ {
@@ -166,6 +167,8 @@ namespace PrivaPub.Infrastructure.Data
await Plain<ObjectRecord>(token, r => r.PostId); await Plain<ObjectRecord>(token, r => r.PostId);
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Gt(d => d.ObjectURI, ""), token); await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Gt(d => d.ObjectURI, ""), token);
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token); await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
await Unique<ThreadCollection>(c => c.URI, Builders<ThreadCollection>.Filter.Gt(c => c.URI, ""), token);
await DB.Default.Index<ThreadCollection>().Key(c => c.ReadAt, KeyType.Ascending).Option(o => o.ExpireAfter = ThreadCollectionLifetime).CreateAsync(token);
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<Downvote>(token, d => d.ActivityURI); await Plain<Downvote>(token, d => d.ActivityURI);
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending) await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending)
+31 -4
View File
@@ -15,6 +15,9 @@ namespace PrivaPub.Infrastructure.Http
public Uri FinalUri { get; init; } public Uri FinalUri { get; init; }
public JsonDocument Document { get; init; } public JsonDocument Document { get; init; }
public JsonElement Root => Document.RootElement; public JsonElement Root => Document.RootElement;
public string ETag { get; init; }
// the origin answered 304 to an If-None-Match: no document, the one read before still stands
public bool NotModified { get; init; }
public void Dispose() => Document?.Dispose(); public void Dispose() => Document?.Dispose();
} }
@@ -24,6 +27,9 @@ namespace PrivaPub.Infrastructure.Http
bool IsAllowed(Uri target); bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
/// <summary>The document, or one that is <see cref="FetchedJson.NotModified"/> when its origin still has the version
/// <paramref name="etag"/> names.</summary>
Task<FetchedJson> GetJsonIfChanged(string url, string accept, string etag, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url); bool FailedTemporarily(string url);
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token); Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token); Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
@@ -291,7 +297,20 @@ namespace PrivaPub.Infrastructure.Http
var exchange = new Exchange(url, HttpScope.Purpose ?? "object"); var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try try
{ {
return await GetJson(url, accept, sign, exchange, token); return await GetJson(url, accept, sign, default, exchange, token);
}
finally
{
Record(exchange);
}
}
public async Task<FetchedJson> GetJsonIfChanged(string url, string accept, string etag, Action<HttpRequestMessage> sign, CancellationToken token)
{
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try
{
return await GetJson(url, accept, sign, etag, exchange, token);
} }
finally finally
{ {
@@ -308,7 +327,7 @@ namespace PrivaPub.Infrastructure.Http
var exchange = new Exchange(url, HttpScope.Purpose ?? "object"); var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try try
{ {
var json = await GetJson(url, accept, sign, exchange, token); var json = await GetJson(url, accept, sign, default, exchange, token);
return (exchange.Status ?? 0, json); return (exchange.Status ?? 0, json);
} }
finally finally
@@ -317,7 +336,7 @@ namespace PrivaPub.Infrastructure.Http
} }
} }
async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, Exchange exchange, CancellationToken token) async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, string etag, Exchange exchange, CancellationToken token)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target)) if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
{ {
@@ -338,11 +357,15 @@ namespace PrivaPub.Infrastructure.Http
for (var hop = 0; hop <= MaxRedirects; hop++) for (var hop = 0; hop <= MaxRedirects; hop++)
{ {
using var request = Request(target, accept); using var request = Request(target, accept);
if (!string.IsNullOrEmpty(etag))
request.Headers.TryAddWithoutValidation("If-None-Match", etag);
sign?.Invoke(request); sign?.Invoke(request);
using var response = await _httpClientFactory.CreateClient(ClientName) using var response = await _httpClientFactory.CreateClient(ClientName)
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
exchange.Answered(response); exchange.Answered(response);
if (response.StatusCode == HttpStatusCode.NotModified && !string.IsNullOrEmpty(etag))
return new FetchedJson { FinalUri = target, ETag = etag, NotModified = true };
if (IsRedirect(response.StatusCode)) if (IsRedirect(response.StatusCode))
{ {
@@ -370,7 +393,7 @@ namespace PrivaPub.Infrastructure.Http
return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large"); return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
exchange.Bytes = body.Length; exchange.Bytes = body.Length;
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) }; return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body), ETag = RawETag(response) };
} }
return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects"); return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects");
} }
@@ -658,6 +681,10 @@ namespace PrivaPub.Infrastructure.Http
return buffer.ToArray(); return buffer.ToArray();
} }
// as sent, since it is only ever sent back: NodeBB's carries no quotes, which the typed header rejects
static string RawETag(HttpResponseMessage response) =>
response.Headers.TryGetValues("ETag", out var values) ? values.FirstOrDefault() : default;
static bool IsRedirect(HttpStatusCode status) => static bool IsRedirect(HttpStatusCode status) =>
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect; or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
// a thread's collection on another server as we last read it whole: its ETag, sent back as If-None-Match so an unchanged
// thread costs its server one 304 (NodeBB's context carries a digest of its posts)
public class ThreadCollection : Entity
{
public string URI { get; set; }
public string ETag { get; set; }
public DateTime ReadAt { get; set; } = DateTime.UtcNow;//expires after Indexes.ThreadCollectionLifetime
}
}
+13 -2
View File
@@ -96,7 +96,9 @@ Priorities, used throughout:
- `Move`. - `Move`.
- `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`. - `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`.
- `FeatureRequest`/`FeatureAuthorization` (4.6). - `FeatureRequest`/`FeatureAuthorization` (4.6).
- **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+). - **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+). It
answers `If-None-Match` with a 304 (a weak ETag over the body, 4.7.3), but the body embeds only the first page, so its
ETag stands for the thread only while the thread fits on that page.
- **Interaction counts:** `likes` and `shares` carry `totalItems`. - **Interaction counts:** `likes` and `shares` carry `totalItems`.
- **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`. - **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`.
- **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967). - **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967).
@@ -659,7 +661,9 @@ What it showed:
- **NodeBB:** - **NodeBB:**
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes. - A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
- Categories are `Group`s **without `followers`**. - Categories are `Group`s **without `followers`**.
- `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`. - `context` is a collection with an **ETag digest** of its posts, sent **without quotes** (not an RFC 9110
entity-tag: .NET's typed header drops it, so PrivaPub keeps the raw value); it carries `totalItems` and its items
inline, and answers `If-None-Match` with a 304. NodeBB refetches with it too.
- Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors. - Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors.
- It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd). - It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd).
- Chats are private Notes, threaded by `inReplyTo` into the room they answer. - Chats are private Notes, threaded by `inReplyTo` into the room they answer.
@@ -855,6 +859,8 @@ without a port, before it gives out its OAuth client.
- Its Mastodon API resolves an account elsewhere only when the search is not limited to a type - Its Mastodon API resolves an account elsewhere only when the search is not limited to a type
(`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route (`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route
(`PUT /api/v1/pleroma/statuses/:id/reactions/:emoji`). (`PUT /api/v1/pleroma/statuses/:id/reactions/:emoji`).
- **Threads:** a post's `context` is `/collections/conversations/<id>`, a collection whose first page lists the
thread's post ids (FEP-7888), with no ETag; no `contextHistory`.
- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/mitra.sh`):** 28 checks pass, with no change to PrivaPub: - **Pasture evidence (2026-10-05, `tools/pasture/scenarios/mitra.sh`):** 28 checks pass, with no change to PrivaPub:
follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote, follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote,
edits and deletions both ways, direct messages both ways, the unfollow, statistics. edits and deletions both ways, direct messages both ways, the unfollow, statistics.
@@ -918,6 +924,11 @@ without a port, before it gives out its OAuth client.
- **Everything in a thread is added to its conversation** (FEP-171b): its edits come only as `Add{Update}`, under the - **Everything in a thread is added to its conversation** (FEP-171b): its edits come only as `Add{Update}`, under the
same activity id as the post's `Create`, and likes as `Add{Like}`. PrivaPub unwraps Create, Update and Delete, telling same activity id as the post's `Create`, and likes as `Add{Like}`. PrivaPub unwraps Create, Update and Delete, telling
an Update from the Create whose id it reuses by what it carries. an Update from the Create whose id it reuses by what it carries.
- **Its conversation container is not named by its posts:** `contextHistory` (and `context`) pointing at
`…/conversation/<uuid>` ride on the activities only; a Note's `context` is the thread's first post and it has no
`contextHistory`, though the FEP asks the first post for one. The container (public threads: unsigned GETs work)
lists the first post's plain `Create`, then an `Add{Create}` for every post. A post's `replies` lists every comment of
the thread, embedded, and is what PrivaPub reads to complete a Forte thread (checked live 2026-10-07).
- **Its own collections name themselves wrongly:** a channel's followers, following and outbox give an `id` with the - **Its own collections name themselves wrongly:** a channel's followers, following and outbox give an `id` with the
gateway path twice and a trailing `?`, which 404s, so their counts are not read. gateway path twice and a trailing `?`, which 404s, so their counts are not read.
- **Running it:** no image is published; `images/forte` builds its tag with composer on Hubzilla's PHP image, behind an - **Running it:** no image is published; `images/forte` builds its tag with composer on Hubzilla's PHP image, behind an
+5 -2
View File
@@ -677,8 +677,11 @@ it, raw where it doesn't.
#### P7 Threads, communities and the social graph #### P7 Threads, communities and the social graph
- **Thread backfill:** - **Thread backfill:**
- read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05), but for - read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05; `contextHistory`
`contextHistory` and ETags:** a persona opening a public remote thread queues `FetchReplies` for the post and its and ETags 2026-10-07: a container's `Add`ed Creates and Updates are read for their posts; a whole read of a collection
that counts its posts or holds them all keeps its ETag, sent back as `If-None-Match`, a 304 checked live on Mastodon
4.7.3 and NodeBB 4.16, whose ETag has no quotes; neither Forte nor Mitra puts `contextHistory` on a post, and Forte's
`replies` already holds its whole thread):** a persona opening a public remote thread queues `FetchReplies` for the post and its
root, at most hourly each. The job reads the thread's FEP-7888 `context` collection (posts or, as FEP-f228 allows, root, at most hourly each. The job reads the thread's FEP-7888 `context` collection (posts or, as FEP-f228 allows,
the activities that made them), else the post's `replies` (PeerTube's `comments`) and theirs, two levels down. It the activities that made them), else the post's `replies` (PeerTube's `comments`) and theirs, two levels down. It
reads at most 5 pages and 100 posts per job, signed by the instance actor, and stores what it lacks through reads at most 5 pages and 100 posts per job, signed by the instance actor, and stores what it lacks through