@fedify/fedify 1.5.0-dev.729 → 1.5.0-dev.731

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGES.md CHANGED
@@ -8,6 +8,21 @@ Version 1.5.0
8
8
 
9
9
  To be released.
10
10
 
11
+ - Improved activity delivery performance with large audiences through
12
+ a two-stage queuing system. Sending activities to many recipients
13
+ (e.g., accounts with many followers) is now significantly faster and uses
14
+ less memory. [[#220]]
15
+
16
+ - Added `FederationQueueOptions.fanout` option.
17
+ - Changed the type of `FederationStartQueueOptions.queue` option to
18
+ `"inbox" | "outbox" | "fanout" | undefined` (was `"inbox" | "outbox" |
19
+ undefined`).
20
+ - Added `SendActivityOptions.fanout` option.
21
+ - Added OpenTelemetry instrumented span `activitypub.fanout`.
22
+ - The `ForwardActivityOptions` interface became a type alias of
23
+ `Omit<SendActivityOptions, "fanout"> & { skipIfUnsigned: boolean }`,
24
+ which is still compatible with the previous version.
25
+
11
26
  - A `Federation` object now can have a canonical origin for web URLs and
12
27
  a canonical host for fediverse handles. This affects the URLs constructed
13
28
  by `Context` objects, and the WebFinger responses.
@@ -65,12 +80,14 @@ To be released.
65
80
  - Added more log messages using the [LogTape] library. Currently the below
66
81
  logger categories are used:
67
82
 
83
+ - `["fedify", "federation", "fanout"]`
68
84
  - `["fedify", "federation", "object"]`
69
85
 
70
86
  [#127]: https://github.com/fedify-dev/fedify/issues/127
71
87
  [#209]: https://github.com/fedify-dev/fedify/issues/209
72
88
  [#211]: https://github.com/fedify-dev/fedify/issues/211
73
89
  [#215]: https://github.com/fedify-dev/fedify/pull/215
90
+ [#220]: https://github.com/fedify-dev/fedify/issues/220
74
91
  [multibase]: https://github.com/multiformats/js-multibase
75
92
 
76
93
 
package/esm/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "@fedify/fedify",
3
- "version": "1.5.0-dev.729+010d765a",
3
+ "version": "1.5.0-dev.731+6bb4c90e",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./mod.ts",
@@ -37,8 +37,10 @@ export class FederationImpl {
37
37
  kvPrefixes;
38
38
  inboxQueue;
39
39
  outboxQueue;
40
+ fanoutQueue;
40
41
  inboxQueueStarted;
41
42
  outboxQueueStarted;
43
+ fanoutQueueStarted;
42
44
  manuallyStartQueue;
43
45
  origin;
44
46
  router;
@@ -83,17 +85,21 @@ export class FederationImpl {
83
85
  if (options.queue == null) {
84
86
  this.inboxQueue = undefined;
85
87
  this.outboxQueue = undefined;
88
+ this.fanoutQueue = undefined;
86
89
  }
87
90
  else if ("enqueue" in options.queue && "listen" in options.queue) {
88
91
  this.inboxQueue = options.queue;
89
92
  this.outboxQueue = options.queue;
93
+ this.fanoutQueue = options.queue;
90
94
  }
91
95
  else {
92
96
  this.inboxQueue = options.queue.inbox;
93
97
  this.outboxQueue = options.queue.outbox;
98
+ this.fanoutQueue = options.queue.fanout;
94
99
  }
95
100
  this.inboxQueueStarted = false;
96
101
  this.outboxQueueStarted = false;
102
+ this.fanoutQueueStarted = false;
97
103
  this.manuallyStartQueue = options.manuallyStartQueue ?? false;
98
104
  if (options.origin != null) {
99
105
  if (typeof options.origin === "string") {
@@ -206,7 +212,7 @@ export class FederationImpl {
206
212
  #getTracer() {
207
213
  return this.tracerProvider.getTracer(metadata.name, metadata.version);
208
214
  }
209
- async #startQueue(ctxData, signal, queue) {
215
+ async _startQueueInternal(ctxData, signal, queue) {
210
216
  if (this.inboxQueue == null && this.outboxQueue == null)
211
217
  return;
212
218
  const logger = getLogger(["fedify", "federation", "queue"]);
@@ -225,13 +231,47 @@ export class FederationImpl {
225
231
  this.outboxQueueStarted = true;
226
232
  promises.push(this.outboxQueue.listen((msg) => this.#listenQueue(ctxData, msg), { signal }));
227
233
  }
234
+ if (this.fanoutQueue != null &&
235
+ this.fanoutQueue !== this.inboxQueue &&
236
+ this.fanoutQueue !== this.outboxQueue &&
237
+ (queue == null || queue === "fanout") &&
238
+ !this.fanoutQueueStarted) {
239
+ logger.debug("Starting a fanout task worker.");
240
+ this.fanoutQueueStarted = true;
241
+ promises.push(this.fanoutQueue.listen((msg) => this.#listenQueue(ctxData, msg), { signal }));
242
+ }
228
243
  await Promise.all(promises);
229
244
  }
230
245
  #listenQueue(ctxData, message) {
231
246
  const tracer = this.#getTracer();
232
247
  const extractedContext = propagation.extract(context.active(), message.traceContext);
233
248
  return withContext({ messageId: message.id }, async () => {
234
- if (message.type === "outbox") {
249
+ if (message.type === "fanout") {
250
+ await tracer.startActiveSpan("activitypub.fanout", {
251
+ kind: SpanKind.CONSUMER,
252
+ attributes: {
253
+ "activitypub.activity.type": message.activityType,
254
+ },
255
+ }, extractedContext, async (span) => {
256
+ if (message.activityId != null) {
257
+ span.setAttribute("activitypub.activity.id", message.activityId);
258
+ }
259
+ try {
260
+ await this.#listenFanoutMessage(ctxData, message);
261
+ }
262
+ catch (e) {
263
+ span.setStatus({
264
+ code: SpanStatusCode.ERROR,
265
+ message: String(e),
266
+ });
267
+ throw e;
268
+ }
269
+ finally {
270
+ span.end();
271
+ }
272
+ });
273
+ }
274
+ else if (message.type === "outbox") {
235
275
  await tracer.startActiveSpan("activitypub.outbox", {
236
276
  kind: SpanKind.CONSUMER,
237
277
  attributes: {
@@ -281,6 +321,38 @@ export class FederationImpl {
281
321
  }
282
322
  });
283
323
  }
324
+ async #listenFanoutMessage(data, message) {
325
+ const logger = getLogger(["fedify", "federation", "fanout"]);
326
+ logger.debug("Fanning out activity {activityId} to {inboxes} inbox(es)...", {
327
+ activityId: message.activityId,
328
+ inboxes: globalThis.Object.keys(message.inboxes).length,
329
+ });
330
+ const keys = await Promise.all(message.keys.map(async ({ keyId, privateKey }) => ({
331
+ keyId: new URL(keyId),
332
+ privateKey: await importJwk(privateKey, "private"),
333
+ })));
334
+ const activity = await Activity.fromJsonLd(message.activity, {
335
+ contextLoader: this.contextLoaderFactory({
336
+ allowPrivateAddress: this.allowPrivateAddress,
337
+ userAgent: this.userAgent,
338
+ }),
339
+ documentLoader: this.documentLoaderFactory({
340
+ allowPrivateAddress: this.allowPrivateAddress,
341
+ userAgent: this.userAgent,
342
+ }),
343
+ tracerProvider: this.tracerProvider,
344
+ });
345
+ const context = this.#createContext(new URL(message.baseUrl), data, {
346
+ documentLoader: this.documentLoaderFactory({
347
+ allowPrivateAddress: this.allowPrivateAddress,
348
+ userAgent: this.userAgent,
349
+ }),
350
+ });
351
+ await this.sendActivity(keys, message.inboxes, activity, {
352
+ collectionSync: message.collectionSync,
353
+ context,
354
+ });
355
+ }
284
356
  async #listenOutboxMessage(_, message, span) {
285
357
  const logger = getLogger(["fedify", "federation", "outbox"]);
286
358
  const logData = {
@@ -485,7 +557,7 @@ export class FederationImpl {
485
557
  });
486
558
  }
487
559
  startQueue(contextData, options = {}) {
488
- return this.#startQueue(contextData, options.signal, options.queue);
560
+ return this._startQueueInternal(contextData, options.signal, options.queue);
489
561
  }
490
562
  createContext(urlOrRequest, contextData) {
491
563
  return urlOrRequest instanceof Request
@@ -1118,33 +1190,9 @@ export class FederationImpl {
1118
1190
  };
1119
1191
  return setters;
1120
1192
  }
1121
- async sendActivity(keys, recipients, activity, options, span) {
1193
+ async sendActivity(keys, inboxes, activity, options) {
1122
1194
  const logger = getLogger(["fedify", "federation", "outbox"]);
1123
- const { preferSharedInbox, immediate, excludeBaseUris, collectionSync, context: ctx, } = options;
1124
- if (keys.length < 1) {
1125
- throw new TypeError("The sender's keys must not be empty.");
1126
- }
1127
- for (const { privateKey } of keys) {
1128
- validateCryptoKey(privateKey, "private");
1129
- }
1130
- for (const activityTransformer of this.activityTransformers) {
1131
- activity = activityTransformer(activity, ctx);
1132
- }
1133
- span?.setAttribute("activitypub.activity.id", activity?.id?.href ?? "");
1134
- if (activity.actorId == null) {
1135
- logger.error("Activity {activityId} to send does not have an actor.", { activity, activityId: activity?.id?.href });
1136
- throw new TypeError("The activity to send must have at least one actor property.");
1137
- }
1138
- const inboxes = extractInboxes({
1139
- recipients: Array.isArray(recipients) ? recipients : [recipients],
1140
- preferSharedInbox,
1141
- excludeBaseUris,
1142
- });
1143
- logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", {
1144
- inboxes: globalThis.Object.keys(inboxes),
1145
- activityId: activity.id?.href,
1146
- activity,
1147
- });
1195
+ const { immediate, collectionSync, context: ctx } = options;
1148
1196
  if (activity.id == null) {
1149
1197
  throw new TypeError("The activity to send must have an id.");
1150
1198
  }
@@ -1238,7 +1286,7 @@ export class FederationImpl {
1238
1286
  keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk });
1239
1287
  }
1240
1288
  if (!this.manuallyStartQueue)
1241
- this.#startQueue(ctx.data);
1289
+ this._startQueueInternal(ctx.data);
1242
1290
  const carrier = {};
1243
1291
  propagation.inject(context.active(), carrier);
1244
1292
  const promises = [];
@@ -1441,7 +1489,7 @@ export class FederationImpl {
1441
1489
  }
1442
1490
  }
1443
1491
  if (!this.manuallyStartQueue)
1444
- this.#startQueue(contextData);
1492
+ this._startQueueInternal(contextData);
1445
1493
  return await handleInbox(request, {
1446
1494
  recipient: route.values.identifier ?? route.values.handle ?? null,
1447
1495
  context,
@@ -1539,6 +1587,7 @@ export class FederationImpl {
1539
1587
  }
1540
1588
  }
1541
1589
  }
1590
+ const FANOUT_THRESHOLD = 5;
1542
1591
  export class ContextImpl {
1543
1592
  url;
1544
1593
  federation;
@@ -1946,7 +1995,9 @@ export class ContextImpl {
1946
1995
  }
1947
1996
  sendActivity(sender, recipients, activity, options = {}) {
1948
1997
  const tracer = this.tracerProvider.getTracer(metadata.name, metadata.version);
1949
- return tracer.startActiveSpan("activitypub.outbox", {
1998
+ return tracer.startActiveSpan(this.federation.outboxQueue == null || options.immediate
1999
+ ? "activitypub.outbox"
2000
+ : "activitypub.fanout", {
1950
2001
  kind: this.federation.outboxQueue == null || options.immediate
1951
2002
  ? SpanKind.CLIENT
1952
2003
  : SpanKind.PRODUCER,
@@ -1974,6 +2025,7 @@ export class ContextImpl {
1974
2025
  });
1975
2026
  }
1976
2027
  async sendActivityInternal(sender, recipients, activity, options = {}, span) {
2028
+ const logger = getLogger(["fedify", "federation", "outbox"]);
1977
2029
  let keys;
1978
2030
  let identifier = null;
1979
2031
  if ("identifier" in sender || "username" in sender || "handle" in sender) {
@@ -1987,7 +2039,7 @@ export class ContextImpl {
1987
2039
  }
1988
2040
  else {
1989
2041
  username = sender.handle;
1990
- getLogger(["fedify", "federation", "outbox"]).warn('The "handle" property for the sender parameter is deprecated; ' +
2042
+ logger.warn('The "handle" property for the sender parameter is deprecated; ' +
1991
2043
  'use "identifier" or "username" instead.', { sender });
1992
2044
  }
1993
2045
  if (this.federation.actorCallbacks?.handleMapper == null) {
@@ -2016,10 +2068,13 @@ export class ContextImpl {
2016
2068
  else {
2017
2069
  keys = [sender];
2018
2070
  }
2019
- const opts = {
2020
- context: this,
2021
- ...options,
2022
- };
2071
+ if (keys.length < 1) {
2072
+ throw new TypeError("The sender's keys must not be empty.");
2073
+ }
2074
+ for (const { privateKey } of keys) {
2075
+ validateCryptoKey(privateKey, "private");
2076
+ }
2077
+ const opts = { context: this };
2023
2078
  let expandedRecipients;
2024
2079
  if (Array.isArray(recipients)) {
2025
2080
  expandedRecipients = recipients;
@@ -2042,7 +2097,55 @@ export class ContextImpl {
2042
2097
  expandedRecipients = [recipients];
2043
2098
  }
2044
2099
  span.setAttribute("activitypub.inboxes", expandedRecipients.length);
2045
- return await this.federation.sendActivity(keys, expandedRecipients, activity, opts, span);
2100
+ for (const activityTransformer of this.federation.activityTransformers) {
2101
+ activity = activityTransformer(activity, this);
2102
+ }
2103
+ span?.setAttribute("activitypub.activity.id", activity?.id?.href ?? "");
2104
+ if (activity.actorId == null) {
2105
+ logger.error("Activity {activityId} to send does not have an actor.", { activity, activityId: activity?.id?.href });
2106
+ throw new TypeError("The activity to send must have at least one actor property.");
2107
+ }
2108
+ const inboxes = extractInboxes({
2109
+ recipients: expandedRecipients,
2110
+ preferSharedInbox: options.preferSharedInbox,
2111
+ excludeBaseUris: options.excludeBaseUris,
2112
+ });
2113
+ logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", {
2114
+ inboxes: globalThis.Object.keys(inboxes),
2115
+ activityId: activity.id?.href,
2116
+ activity,
2117
+ });
2118
+ if (this.federation.fanoutQueue == null || options.immediate ||
2119
+ options.fanout === "skip" || (options.fanout ?? "auto") === "auto" &&
2120
+ globalThis.Object.keys(inboxes).length < FANOUT_THRESHOLD) {
2121
+ await this.federation.sendActivity(keys, inboxes, activity, opts);
2122
+ return;
2123
+ }
2124
+ const keyJwkPairs = await Promise.all(keys.map(async ({ keyId, privateKey }) => ({
2125
+ keyId: keyId.href,
2126
+ privateKey: await exportJwk(privateKey),
2127
+ })));
2128
+ const carrier = {};
2129
+ propagation.inject(context.active(), carrier);
2130
+ const message = {
2131
+ type: "fanout",
2132
+ id: dntShim.crypto.randomUUID(),
2133
+ baseUrl: this.origin,
2134
+ keys: keyJwkPairs,
2135
+ inboxes: globalThis.Object.fromEntries(globalThis.Object.entries(inboxes).map(([k, { actorIds, sharedInbox }]) => [k, { actorIds: [...actorIds], sharedInbox }])),
2136
+ activity: await activity.toJsonLd({
2137
+ format: "compact",
2138
+ contextLoader: this.contextLoader,
2139
+ }),
2140
+ activityId: activity.id?.href,
2141
+ activityType: getTypeId(activity).href,
2142
+ collectionSync: opts.collectionSync,
2143
+ traceContext: carrier,
2144
+ };
2145
+ if (!this.federation.manuallyStartQueue) {
2146
+ this.federation._startQueueInternal(this.data);
2147
+ }
2148
+ this.federation.fanoutQueue.enqueue(message);
2046
2149
  }
2047
2150
  async *getFollowers(identifier) {
2048
2151
  if (this.federation.followersCallbacks == null) {