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

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,18 @@ 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
+
11
23
  - A `Federation` object now can have a canonical origin for web URLs and
12
24
  a canonical host for fediverse handles. This affects the URLs constructed
13
25
  by `Context` objects, and the WebFinger responses.
@@ -65,12 +77,14 @@ To be released.
65
77
  - Added more log messages using the [LogTape] library. Currently the below
66
78
  logger categories are used:
67
79
 
80
+ - `["fedify", "federation", "fanout"]`
68
81
  - `["fedify", "federation", "object"]`
69
82
 
70
83
  [#127]: https://github.com/fedify-dev/fedify/issues/127
71
84
  [#209]: https://github.com/fedify-dev/fedify/issues/209
72
85
  [#211]: https://github.com/fedify-dev/fedify/issues/211
73
86
  [#215]: https://github.com/fedify-dev/fedify/pull/215
87
+ [#220]: https://github.com/fedify-dev/fedify/issues/220
74
88
  [multibase]: https://github.com/multiformats/js-multibase
75
89
 
76
90
 
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.730+390610bf",
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,33 @@ export class FederationImpl {
281
321
  }
282
322
  });
283
323
  }
324
+ async #listenFanoutMessage(data, message) {
325
+ const keys = await Promise.all(message.keys.map(async ({ keyId, privateKey }) => ({
326
+ keyId: new URL(keyId),
327
+ privateKey: await importJwk(privateKey, "private"),
328
+ })));
329
+ const activity = await Activity.fromJsonLd(message.activity, {
330
+ contextLoader: this.contextLoaderFactory({
331
+ allowPrivateAddress: this.allowPrivateAddress,
332
+ userAgent: this.userAgent,
333
+ }),
334
+ documentLoader: this.documentLoaderFactory({
335
+ allowPrivateAddress: this.allowPrivateAddress,
336
+ userAgent: this.userAgent,
337
+ }),
338
+ tracerProvider: this.tracerProvider,
339
+ });
340
+ const context = this.#createContext(new URL(message.baseUrl), data, {
341
+ documentLoader: this.documentLoaderFactory({
342
+ allowPrivateAddress: this.allowPrivateAddress,
343
+ userAgent: this.userAgent,
344
+ }),
345
+ });
346
+ await this.sendActivity(keys, message.inboxes, activity, {
347
+ collectionSync: message.collectionSync,
348
+ context,
349
+ });
350
+ }
284
351
  async #listenOutboxMessage(_, message, span) {
285
352
  const logger = getLogger(["fedify", "federation", "outbox"]);
286
353
  const logData = {
@@ -485,7 +552,7 @@ export class FederationImpl {
485
552
  });
486
553
  }
487
554
  startQueue(contextData, options = {}) {
488
- return this.#startQueue(contextData, options.signal, options.queue);
555
+ return this._startQueueInternal(contextData, options.signal, options.queue);
489
556
  }
490
557
  createContext(urlOrRequest, contextData) {
491
558
  return urlOrRequest instanceof Request
@@ -1118,33 +1185,9 @@ export class FederationImpl {
1118
1185
  };
1119
1186
  return setters;
1120
1187
  }
1121
- async sendActivity(keys, recipients, activity, options, span) {
1188
+ async sendActivity(keys, inboxes, activity, options) {
1122
1189
  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
- });
1190
+ const { immediate, collectionSync, context: ctx } = options;
1148
1191
  if (activity.id == null) {
1149
1192
  throw new TypeError("The activity to send must have an id.");
1150
1193
  }
@@ -1238,7 +1281,7 @@ export class FederationImpl {
1238
1281
  keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk });
1239
1282
  }
1240
1283
  if (!this.manuallyStartQueue)
1241
- this.#startQueue(ctx.data);
1284
+ this._startQueueInternal(ctx.data);
1242
1285
  const carrier = {};
1243
1286
  propagation.inject(context.active(), carrier);
1244
1287
  const promises = [];
@@ -1441,7 +1484,7 @@ export class FederationImpl {
1441
1484
  }
1442
1485
  }
1443
1486
  if (!this.manuallyStartQueue)
1444
- this.#startQueue(contextData);
1487
+ this._startQueueInternal(contextData);
1445
1488
  return await handleInbox(request, {
1446
1489
  recipient: route.values.identifier ?? route.values.handle ?? null,
1447
1490
  context,
@@ -1539,6 +1582,7 @@ export class FederationImpl {
1539
1582
  }
1540
1583
  }
1541
1584
  }
1585
+ const FANOUT_THRESHOLD = 5;
1542
1586
  export class ContextImpl {
1543
1587
  url;
1544
1588
  federation;
@@ -1946,7 +1990,9 @@ export class ContextImpl {
1946
1990
  }
1947
1991
  sendActivity(sender, recipients, activity, options = {}) {
1948
1992
  const tracer = this.tracerProvider.getTracer(metadata.name, metadata.version);
1949
- return tracer.startActiveSpan("activitypub.outbox", {
1993
+ return tracer.startActiveSpan(this.federation.outboxQueue == null || options.immediate
1994
+ ? "activitypub.outbox"
1995
+ : "activitypub.fanout", {
1950
1996
  kind: this.federation.outboxQueue == null || options.immediate
1951
1997
  ? SpanKind.CLIENT
1952
1998
  : SpanKind.PRODUCER,
@@ -1974,6 +2020,7 @@ export class ContextImpl {
1974
2020
  });
1975
2021
  }
1976
2022
  async sendActivityInternal(sender, recipients, activity, options = {}, span) {
2023
+ const logger = getLogger(["fedify", "federation", "outbox"]);
1977
2024
  let keys;
1978
2025
  let identifier = null;
1979
2026
  if ("identifier" in sender || "username" in sender || "handle" in sender) {
@@ -1987,7 +2034,7 @@ export class ContextImpl {
1987
2034
  }
1988
2035
  else {
1989
2036
  username = sender.handle;
1990
- getLogger(["fedify", "federation", "outbox"]).warn('The "handle" property for the sender parameter is deprecated; ' +
2037
+ logger.warn('The "handle" property for the sender parameter is deprecated; ' +
1991
2038
  'use "identifier" or "username" instead.', { sender });
1992
2039
  }
1993
2040
  if (this.federation.actorCallbacks?.handleMapper == null) {
@@ -2016,10 +2063,13 @@ export class ContextImpl {
2016
2063
  else {
2017
2064
  keys = [sender];
2018
2065
  }
2019
- const opts = {
2020
- context: this,
2021
- ...options,
2022
- };
2066
+ if (keys.length < 1) {
2067
+ throw new TypeError("The sender's keys must not be empty.");
2068
+ }
2069
+ for (const { privateKey } of keys) {
2070
+ validateCryptoKey(privateKey, "private");
2071
+ }
2072
+ const opts = { context: this };
2023
2073
  let expandedRecipients;
2024
2074
  if (Array.isArray(recipients)) {
2025
2075
  expandedRecipients = recipients;
@@ -2042,7 +2092,55 @@ export class ContextImpl {
2042
2092
  expandedRecipients = [recipients];
2043
2093
  }
2044
2094
  span.setAttribute("activitypub.inboxes", expandedRecipients.length);
2045
- return await this.federation.sendActivity(keys, expandedRecipients, activity, opts, span);
2095
+ for (const activityTransformer of this.federation.activityTransformers) {
2096
+ activity = activityTransformer(activity, this);
2097
+ }
2098
+ span?.setAttribute("activitypub.activity.id", activity?.id?.href ?? "");
2099
+ if (activity.actorId == null) {
2100
+ logger.error("Activity {activityId} to send does not have an actor.", { activity, activityId: activity?.id?.href });
2101
+ throw new TypeError("The activity to send must have at least one actor property.");
2102
+ }
2103
+ const inboxes = extractInboxes({
2104
+ recipients: expandedRecipients,
2105
+ preferSharedInbox: options.preferSharedInbox,
2106
+ excludeBaseUris: options.excludeBaseUris,
2107
+ });
2108
+ logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", {
2109
+ inboxes: globalThis.Object.keys(inboxes),
2110
+ activityId: activity.id?.href,
2111
+ activity,
2112
+ });
2113
+ if (this.federation.fanoutQueue == null || options.immediate ||
2114
+ options.fanout === "skip" || (options.fanout ?? "auto") === "auto" &&
2115
+ globalThis.Object.keys(inboxes).length < FANOUT_THRESHOLD) {
2116
+ await this.federation.sendActivity(keys, inboxes, activity, opts);
2117
+ return;
2118
+ }
2119
+ const keyJwkPairs = await Promise.all(keys.map(async ({ keyId, privateKey }) => ({
2120
+ keyId: keyId.href,
2121
+ privateKey: await exportJwk(privateKey),
2122
+ })));
2123
+ const carrier = {};
2124
+ propagation.inject(context.active(), carrier);
2125
+ const message = {
2126
+ type: "fanout",
2127
+ id: dntShim.crypto.randomUUID(),
2128
+ baseUrl: this.origin,
2129
+ keys: keyJwkPairs,
2130
+ inboxes: globalThis.Object.fromEntries(globalThis.Object.entries(inboxes).map(([k, { actorIds, sharedInbox }]) => [k, { actorIds: [...actorIds], sharedInbox }])),
2131
+ activity: await activity.toJsonLd({
2132
+ format: "compact",
2133
+ contextLoader: this.contextLoader,
2134
+ }),
2135
+ activityId: activity.id?.href,
2136
+ activityType: getTypeId(activity).href,
2137
+ collectionSync: opts.collectionSync,
2138
+ traceContext: carrier,
2139
+ };
2140
+ if (!this.federation.manuallyStartQueue) {
2141
+ this.federation._startQueueInternal(this.data);
2142
+ }
2143
+ this.federation.fanoutQueue.enqueue(message);
2046
2144
  }
2047
2145
  async *getFollowers(identifier) {
2048
2146
  if (this.federation.followersCallbacks == null) {