Skip to content

Commit ce1bdc2

Browse files
authored
Merge pull request #544 from dahlia/ordering-key
Add `orderingKey` option to `SendActivityOptions`
2 parents 4b3366b + 22a5fb0 commit ce1bdc2

6 files changed

Lines changed: 291 additions & 34 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,15 @@ To be released.
101101
with different ordering keys can be processed in parallel. This helps
102102
prevent race conditions when processing related activities (e.g., ensuring
103103
a `Delete` activity is processed after a `Create` activity for the same
104-
object). [[#538], [#540]]
104+
object). [[#536], [#538], [#540], [#544]]
105105

106106
- Added `MessageQueueEnqueueOptions.orderingKey` property.
107107
- All properties in `MessageQueueEnqueueOptions` are now `readonly`.
108108
- `InProcessMessageQueue` now supports the `orderingKey` option.
109+
- Added `SendActivityOptions.orderingKey` option to ensure ordered
110+
delivery of activities for the same object. When specified, activities
111+
with the same `orderingKey` are guaranteed to be delivered in order
112+
to each recipient server.
109113

110114
[#280]: https://github.com/fedify-dev/fedify/issues/280
111115
[#366]: https://github.com/fedify-dev/fedify/issues/366
@@ -122,8 +126,10 @@ To be released.
122126
[#466]: https://github.com/fedify-dev/fedify/issues/466
123127
[#499]: https://github.com/fedify-dev/fedify/issues/499
124128
[#506]: https://github.com/fedify-dev/fedify/pull/506
129+
[#536]: https://github.com/fedify-dev/fedify/issues/536
125130
[#538]: https://github.com/fedify-dev/fedify/issues/538
126131
[#540]: https://github.com/fedify-dev/fedify/pull/540
132+
[#544]: https://github.com/fedify-dev/fedify/pull/544
127133

128134
### @fedify/cli
129135

docs/manual/send.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,85 @@ await ctx.sendActivity(
529529
~~~~
530530

531531

532+
Ensuring ordered delivery
533+
-------------------------
534+
535+
*This API is available since Fedify 2.0.0.*
536+
537+
When sending multiple related activities for the same object
538+
(e.g., `Create(Note)` followed by `Delete(Note)` for the same `Note`),
539+
it's important that they arrive at recipient servers in the correct order.
540+
Without ordering guarantees, a `Delete(Note)` activity might arrive before
541+
the `Create(Note)` activity, causing the recipient to ignore the deletion and
542+
leaving a “zombie post” that should have been deleted.
543+
544+
To ensure ordered delivery, you can use the `~SendActivityOptions.orderingKey`
545+
option in the `~Context.sendActivity()` method:
546+
547+
~~~~ typescript twoslash
548+
import type { Context } from "@fedify/fedify";
549+
import { Create, Delete, Note, type Recipient } from "@fedify/vocab";
550+
const ctx = null as unknown as Context<void>;
551+
const recipients: Recipient[] = [];
552+
const noteId = new URL("https://example.com/notes/123");
553+
// ---cut-before---
554+
// Create activity
555+
await ctx.sendActivity(
556+
{ identifier: "alice" },
557+
recipients,
558+
new Create({
559+
id: new URL("#create", noteId),
560+
actor: ctx.getActorUri("alice"),
561+
object: new Note({ id: noteId }),
562+
}),
563+
{ orderingKey: noteId.href }, // [!code highlight]
564+
);
565+
566+
// Delete activity - guaranteed to arrive after Create
567+
await ctx.sendActivity(
568+
{ identifier: "alice" },
569+
recipients,
570+
new Delete({
571+
id: new URL("#delete", noteId),
572+
actor: ctx.getActorUri("alice"),
573+
object: noteId,
574+
}),
575+
{ orderingKey: noteId.href }, // [!code highlight]
576+
);
577+
~~~~
578+
579+
Activities with the same `~SendActivityOptions.orderingKey` are guaranteed to be
580+
delivered in the order they were enqueued, per recipient server. Activities
581+
with different `~SendActivityOptions.orderingKey` values (or no
582+
`~SendActivityOptions.orderingKey`) can be delivered in parallel for maximum
583+
throughput.
584+
585+
### When to use `~SendActivityOptions.orderingKey`
586+
587+
Use `~SendActivityOptions.orderingKey` when you have activities that must be
588+
processed in a specific order:
589+
590+
- `Create(Note)``Update(Note)``Delete(Note)` for the same object
591+
- `Follow(Person)``Undo(Follow(Person))` for the same target
592+
- Any sequence where later activities depend on earlier ones
593+
594+
Don't use `~SendActivityOptions.orderingKey` for unrelated activities,
595+
as it unnecessarily reduces parallelism.
596+
597+
### How it works
598+
599+
When you specify an `~SendActivityOptions.orderingKey`:
600+
601+
1. During fan-out, the key is passed as-is to the fanout queue
602+
2. When individual delivery tasks are created, the key is transformed to
603+
`${orderingKey}\n${recipientServerOrigin}` to ensure ordering is maintained
604+
per-recipient-server while allowing parallel delivery to different servers
605+
606+
This means Server A can receive a `Delete(Note)` immediately after its
607+
`Create(Note)` completes, without waiting for Server Z's `Create(Note)` to
608+
finish.
609+
610+
532611
Immediately sending an activity
533612
-------------------------------
534613

packages/fedify/src/federation/context.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ export interface SendActivityOptions {
746746
/**
747747
* Whether to prefer the shared inbox for the recipients.
748748
*/
749-
preferSharedInbox?: boolean;
749+
readonly preferSharedInbox?: boolean;
750750

751751
/**
752752
* Whether to send the activity immediately, without enqueuing it.
@@ -755,7 +755,7 @@ export interface SendActivityOptions {
755755
*
756756
* @since 0.3.0
757757
*/
758-
immediate?: boolean;
758+
readonly immediate?: boolean;
759759

760760
/**
761761
* Determines how activities are queued when sent to multiple recipients.
@@ -773,7 +773,7 @@ export interface SendActivityOptions {
773773
* @default `"auto"`
774774
* @since 1.5.0
775775
*/
776-
fanout?: "auto" | "skip" | "force";
776+
readonly fanout?: "auto" | "skip" | "force";
777777

778778
/**
779779
* The base URIs to exclude from the recipients' inboxes. It is useful
@@ -784,6 +784,21 @@ export interface SendActivityOptions {
784784
* @since 0.9.0
785785
*/
786786
readonly excludeBaseUris?: readonly URL[];
787+
788+
/**
789+
* An optional key to ensure ordered delivery of activities. Activities with
790+
* the same `orderingKey` are guaranteed to be delivered in the order they
791+
* were enqueued, per recipient server.
792+
*
793+
* Typical use case: pass the object ID (e.g., `Note` ID) to ensure that
794+
* `Create`, `Update`, and `Delete` activities for the same object are
795+
* delivered in order.
796+
*
797+
* When omitted, no ordering is guaranteed (maximum parallelism).
798+
*
799+
* @since 2.0.0
800+
*/
801+
readonly orderingKey?: string;
787802
}
788803

789804
/**

packages/fedify/src/federation/middleware.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2233,6 +2233,7 @@ test("ContextImpl.sendActivity()", async (t) => {
22332233
},
22342234
},
22352235
keys: queue.messages[0].type === "fanout" ? queue.messages[0].keys : [],
2236+
orderingKey: undefined,
22362237
traceContext: {},
22372238
},
22382239
]);
@@ -2370,6 +2371,85 @@ test("ContextImpl.sendActivity()", async (t) => {
23702371
assertNotEquals(collectionSyncHeader, null);
23712372
});
23722373

2374+
queue.clear();
2375+
2376+
await t.step('orderingKey with fanout: "force"', async () => {
2377+
const activity = new vocab.Create({
2378+
id: new URL("https://example.com/activity/ordering-1"),
2379+
actor: new URL("https://example.com/person"),
2380+
});
2381+
await ctx2.sendActivity(
2382+
{ username: "john" },
2383+
{
2384+
id: new URL("https://example.com/recipient"),
2385+
inboxId: new URL("https://example.com/inbox"),
2386+
},
2387+
activity,
2388+
{ fanout: "force", orderingKey: "https://example.com/note/1" },
2389+
);
2390+
assertEquals(queue.messages.length, 1);
2391+
const fanoutMessage = queue.messages[0];
2392+
assertEquals(fanoutMessage.type, "fanout");
2393+
if (fanoutMessage.type === "fanout") {
2394+
assertEquals(
2395+
fanoutMessage.orderingKey,
2396+
"https://example.com/note/1",
2397+
);
2398+
}
2399+
});
2400+
2401+
queue.clear();
2402+
2403+
await t.step('orderingKey with fanout: "skip"', async () => {
2404+
const activity = new vocab.Create({
2405+
id: new URL("https://example.com/activity/ordering-2"),
2406+
actor: new URL("https://example.com/person"),
2407+
});
2408+
await ctx2.sendActivity(
2409+
{ username: "john" },
2410+
{
2411+
id: new URL("https://example.com/recipient"),
2412+
inboxId: new URL("https://example.com/inbox"),
2413+
},
2414+
activity,
2415+
{ fanout: "skip", orderingKey: "https://example.com/note/2" },
2416+
);
2417+
assertEquals(queue.messages.length, 1);
2418+
const outboxMessage = queue.messages[0];
2419+
assertEquals(outboxMessage.type, "outbox");
2420+
// outbox message should have orderingKey transformed to include inbox origin
2421+
if (outboxMessage.type === "outbox") {
2422+
assertEquals(
2423+
outboxMessage.orderingKey,
2424+
"https://example.com/note/2\nhttps://example.com",
2425+
);
2426+
}
2427+
});
2428+
2429+
queue.clear();
2430+
2431+
await t.step("orderingKey not specified", async () => {
2432+
const activity = new vocab.Create({
2433+
id: new URL("https://example.com/activity/ordering-3"),
2434+
actor: new URL("https://example.com/person"),
2435+
});
2436+
await ctx2.sendActivity(
2437+
{ username: "john" },
2438+
{
2439+
id: new URL("https://example.com/recipient"),
2440+
inboxId: new URL("https://example.com/inbox"),
2441+
},
2442+
activity,
2443+
{ fanout: "force" },
2444+
);
2445+
assertEquals(queue.messages.length, 1);
2446+
const fanoutMessage2 = queue.messages[0];
2447+
assertEquals(fanoutMessage2.type, "fanout");
2448+
if (fanoutMessage2.type === "fanout") {
2449+
assertEquals(fanoutMessage2.orderingKey, undefined);
2450+
}
2451+
});
2452+
23732453
fetchMock.hardReset();
23742454
});
23752455

0 commit comments

Comments
 (0)