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

@@ -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)));
}
[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]
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, (int Status, TimeSpan Delay)> _answers = new();
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
readonly ConcurrentDictionary<string, string> _etags = new();
public int Port { get; }
public string A => $"http://127.0.0.1:{Port}";
@@ -64,6 +65,15 @@ namespace PrivaPub.Tests.Support
context.Response.StatusCode = StatusCodes.Status404NotFound;
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;
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");
// 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 ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);