matrix-nodejs-homeserver 0.3.3 → 0.3.4

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.
@@ -1,53 +1,150 @@
1
1
  //#region src/core/notifier.ts
2
+ /** Users whose latest wake-up position is remembered (#50); the oldest are forgotten first. */
3
+ const RECENT_USERS = 1e5;
4
+ /** How soon held wake-ups are looked at again while the position has not reached them. */
5
+ const RELEASE_RETRY_MS = 10;
2
6
  /**
3
7
  * Wakes long-polling `/sync` requests when something relevant happens. Notifications
4
8
  * are broadcast through the cluster adapter so waiters on other instances wake too.
9
+ *
10
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/50: every wake-up carries the stream
11
+ * position of its change, or an upper bound of it. Writes can complete out of order, so a change
12
+ * may be committed while an earlier write still holds the position below it. As in Synapse
13
+ * (`Notifier._notify_pending_new_room_events`), such a wake-up is held here until the position
14
+ * reaches it: a request is only woken for changes it can see.
5
15
  */
6
16
  var Notifier = class {
7
- waiters = /* @__PURE__ */ new Map();
17
+ listeners = /* @__PURE__ */ new Map();
18
+ /** Each user's latest wake-up position, oldest first (re-inserted on update). */
19
+ recent = /* @__PURE__ */ new Map();
8
20
  cluster;
9
21
  instanceId;
10
- constructor(cluster, instanceId) {
22
+ lastAllocated;
23
+ position;
24
+ /** Wake-ups whose change is beyond the position known to be visible. */
25
+ held = [];
26
+ /** The highest position known to be visible. */
27
+ visible = 0;
28
+ release = null;
29
+ /**
30
+ * `lastAllocated`: the highest stream position this instance has allocated (an upper bound of
31
+ * its changes); `position`: the position below which everything is visible.
32
+ */
33
+ constructor(cluster, instanceId, lastAllocated = () => 0, position = async () => Number.POSITIVE_INFINITY) {
11
34
  this.cluster = cluster;
12
35
  this.instanceId = instanceId;
36
+ this.lastAllocated = lastAllocated;
37
+ this.position = position;
13
38
  cluster.subscribe("notify", (msg) => {
14
- if (msg?.from !== this.instanceId && Array.isArray(msg?.users)) this.wakeLocal(msg.users);
39
+ if (msg?.from !== this.instanceId && Array.isArray(msg?.users)) this.hold(msg.users, Number(msg.pos) || 0);
15
40
  });
16
41
  }
17
- notify(userIds) {
42
+ /**
43
+ * Wake `userIds`. `pos` is the stream position of the change; without it, the highest position
44
+ * this instance has allocated stands in, which is never below it.
45
+ */
46
+ notify(userIds, pos) {
18
47
  const users = [...userIds];
19
48
  if (!users.length) return;
20
- this.wakeLocal(users);
49
+ const at = pos ?? this.lastAllocated();
50
+ this.hold(users, at);
21
51
  this.cluster.publish("notify", {
22
52
  from: this.instanceId,
23
- users
53
+ users,
54
+ pos: at
24
55
  }).catch(() => {});
25
56
  }
26
- wakeLocal(userIds) {
57
+ /** The position may have moved (a write of this instance completed): release what it covers. */
58
+ advanced() {
59
+ if (this.held.length) this.scheduleRelease(0);
60
+ }
61
+ /** Wake now if the change is visible, otherwise once the position reaches it. */
62
+ hold(users, pos) {
63
+ if (pos <= this.visible) return this.wakeLocal(users, pos);
64
+ this.held.push({
65
+ users,
66
+ pos
67
+ });
68
+ this.scheduleRelease(0);
69
+ }
70
+ scheduleRelease(delay) {
71
+ if (this.release) return;
72
+ this.release = setTimeout(() => {
73
+ this.release = null;
74
+ this.releaseHeld().catch(() => this.scheduleRelease(RELEASE_RETRY_MS));
75
+ }, delay);
76
+ this.release.unref?.();
77
+ }
78
+ async releaseHeld() {
79
+ const highest = Math.max(...this.held.map((h) => h.pos));
80
+ let pos = await this.position(false);
81
+ if (pos < highest) pos = await this.position(true);
82
+ if (pos > this.visible) this.visible = pos;
83
+ const due = this.held.filter((h) => h.pos <= this.visible);
84
+ this.held = this.held.filter((h) => h.pos > this.visible);
85
+ for (const h of due) this.wakeLocal(h.users, h.pos);
86
+ if (this.held.length) this.scheduleRelease(RELEASE_RETRY_MS);
87
+ }
88
+ wakeLocal(userIds, pos) {
27
89
  for (const u of userIds) {
28
- const set = this.waiters.get(u);
90
+ const previous = this.recent.get(u) ?? 0;
91
+ this.recent.delete(u);
92
+ this.recent.set(u, Math.max(previous, pos));
93
+ if (this.recent.size > RECENT_USERS) this.recent.delete(this.recent.keys().next().value);
94
+ const set = this.listeners.get(u);
29
95
  if (!set) continue;
30
- this.waiters.delete(u);
31
- for (const wake of set) wake();
96
+ this.listeners.delete(u);
97
+ for (const wake of set) wake(pos);
32
98
  }
33
99
  }
34
100
  notifyAll() {
35
- this.wakeLocal([...this.waiters.keys()]);
36
- }
37
- wait(userId, timeoutMs, signal) {
38
- return new Promise((resolve) => {
39
- let set = this.waiters.get(userId);
40
- if (!set) this.waiters.set(userId, set = /* @__PURE__ */ new Set());
41
- const done = () => {
42
- clearTimeout(timer);
43
- set.delete(done);
44
- signal?.removeEventListener("abort", done);
45
- resolve();
46
- };
47
- const timer = setTimeout(done, timeoutMs);
48
- signal?.addEventListener("abort", done, { once: true });
49
- set.add(done);
50
- });
101
+ for (const [u, set] of this.listeners) {
102
+ this.listeners.delete(u);
103
+ for (const wake of set) wake(0);
104
+ }
105
+ }
106
+ listen(userId) {
107
+ let woken = null;
108
+ let resolveWait = null;
109
+ let set = this.listeners.get(userId);
110
+ if (!set) this.listeners.set(userId, set = /* @__PURE__ */ new Set());
111
+ const onWake = (pos) => {
112
+ woken = Math.max(woken ?? 0, pos);
113
+ resolveWait?.(woken);
114
+ };
115
+ set.add(onWake);
116
+ const remove = () => {
117
+ const current = this.listeners.get(userId);
118
+ current?.delete(onWake);
119
+ if (current && current.size === 0) this.listeners.delete(userId);
120
+ };
121
+ return {
122
+ recent: this.recent.get(userId) ?? 0,
123
+ wait: (timeoutMs, signal) => {
124
+ if (woken !== null) return Promise.resolve(woken);
125
+ return new Promise((resolve) => {
126
+ const done = (result) => {
127
+ clearTimeout(timer);
128
+ signal?.removeEventListener("abort", aborted);
129
+ resolveWait = null;
130
+ remove();
131
+ resolve(result);
132
+ };
133
+ const aborted = () => done(null);
134
+ const timer = setTimeout(() => done(null), timeoutMs);
135
+ signal?.addEventListener("abort", aborted, { once: true });
136
+ resolveWait = (pos) => done(pos);
137
+ });
138
+ },
139
+ cancel: () => {
140
+ remove();
141
+ resolveWait?.(null);
142
+ }
143
+ };
144
+ }
145
+ /** Wait for a wake-up of `userId` (for callers that do not track positions). */
146
+ async wait(userId, timeoutMs, signal) {
147
+ await this.listen(userId).wait(timeoutMs, signal);
51
148
  }
52
149
  };
53
150
  //#endregion
@@ -7,6 +7,14 @@ import { Homeserver } from "../homeserver.js";
7
7
  * Push rule evaluation and push gateway delivery. Every new event is evaluated for
8
8
  * every local member; matches are stored for unread counts and `/notifications`.
9
9
  */
10
+ type Counts = {
11
+ notification_count: number;
12
+ highlight_count: number;
13
+ };
14
+ export interface UnreadCounts {
15
+ readonly room: Readonly<Counts>;
16
+ readonly threads: Readonly<Record<string, Readonly<Counts>>>;
17
+ }
10
18
  export declare class PushService {
11
19
  private hs;
12
20
  constructor(hs: Homeserver);
@@ -18,16 +26,8 @@ export declare class PushService {
18
26
  unthreaded: number;
19
27
  threads: Map<string, number>;
20
28
  }): boolean;
21
- unreadCounts(userId: string, roomId: string): Promise<{
22
- room: {
23
- notification_count: number;
24
- highlight_count: number;
25
- };
26
- threads: Record<string, {
27
- notification_count: number;
28
- highlight_count: number;
29
- }>;
30
- }>;
29
+ unreadCounts(userId: string, roomId: string): Promise<UnreadCounts>;
30
+ private countUnread;
31
31
  private deliver;
32
32
  /** Rows for `GET /notifications`. */
33
33
  list(userId: string, opts: {
@@ -43,4 +43,5 @@ export declare class PushService {
43
43
  stream: any;
44
44
  }[]>;
45
45
  }
46
- //#endregion
46
+ //#endregion
47
+ export {};
package/dist/core/push.js CHANGED
@@ -6,6 +6,7 @@ import { globMatch, globSearchWords } from "./glob.js";
6
6
  import { KINDS, readOnlyRules } from "./pushrules.js";
7
7
  import { isSubscribed } from "../api/threadsubs.js";
8
8
  import { upsertMany } from "../storage/batch.js";
9
+ import { freeze } from "./changes.js";
9
10
  //#region src/core/push.ts
10
11
  /** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
11
12
  function valueAtPath(obj, path) {
@@ -85,10 +86,6 @@ function threadIdOf(event) {
85
86
  const rel = event.content?.[M.RELATES_TO];
86
87
  return isObject(rel) && rel.rel_type === M.THREAD && typeof rel.event_id === "string" ? rel.event_id : "main";
87
88
  }
88
- /**
89
- * Push rule evaluation and push gateway delivery. Every new event is evaluated for
90
- * every local member; matches are stored for unread counts and `/notifications`.
91
- */
92
89
  var PushService = class {
93
90
  hs;
94
91
  constructor(hs) {
@@ -176,6 +173,11 @@ var PushService = class {
176
173
  return row.stream <= read.unthreaded || row.stream <= (read.threads.get(row.thread_id) ?? 0);
177
174
  }
178
175
  async unreadCounts(userId, roomId) {
176
+ const changes = this.hs.changes;
177
+ if (!changes) return this.countUnread(userId, roomId);
178
+ return changes.values.unreadCounts.get(`${userId}|${roomId}`, async () => freeze(await this.countUnread(userId, roomId)));
179
+ }
180
+ async countUnread(userId, roomId) {
179
181
  const read = await this.readPositions(userId, roomId);
180
182
  const unthreaded = read.unthreaded;
181
183
  const rows = await this.hs.db.findMany("notifications", {
@@ -731,6 +731,12 @@ var RoomService = class {
731
731
  events: touched,
732
732
  sharing: true
733
733
  });
734
+ const changedMembers = /* @__PURE__ */ new Set();
735
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(oldIds), ...Object.keys(newIds)])) {
736
+ const [type, userId] = splitStateKey(k);
737
+ if (type === M.ROOM.MEMBER && oldIds[k] !== newIds[k] && this.hs.isLocalUser(userId)) changedMembers.add(userId);
738
+ }
739
+ if (changedMembers.size) this.hs.notifier.notify([...changedMembers, ...await this.interestedUsers(roomId)]);
734
740
  for (const k of Object.keys(oldIds)) {
735
741
  const [type, userId] = splitStateKey(k);
736
742
  if (type !== M.ROOM.MEMBER || this.hs.isLocalUser(userId) || oldIds[k] === newIds[k]) continue;
@@ -995,7 +1001,7 @@ var RoomService = class {
995
1001
  if (this.hs.appservices.registrations.length) await this.hs.appservices.onEvent(view, newState).catch((err) => this.hs.logger.warn("Appservice push failed", err));
996
1002
  if (isState) await this.hs.delayedEvents.onStateEvent(roomId, pdu.type, pdu.state_key, sender).catch(() => {});
997
1003
  wake = async () => {
998
- this.hs.notifier.notify(await this.interestedUsers(roomId));
1004
+ this.hs.notifier.notify(await this.interestedUsers(roomId), backfill ? void 0 : stream);
999
1005
  this.hs.emit("event", toClientEvent(view));
1000
1006
  if (this.hs.adminRoom) this.hs.adminRoom.onEvent(view).catch((err) => this.hs.logger.error("Admin command failed", err));
1001
1007
  };
@@ -14,7 +14,7 @@ var Semaphore = class {
14
14
  return await fn();
15
15
  } finally {
16
16
  const next = this.waiting.shift();
17
- if (next) next();
17
+ if (next) setImmediate(next);
18
18
  else this.active--;
19
19
  }
20
20
  }
@@ -26,6 +26,7 @@ import { SettingsService } from "./core/settings.js";
26
26
  import { AdminRoomOptions, AdminRoomService } from "./core/adminroom.js";
27
27
  import { ResolvedUserAuth, UserAuthAdapter } from "./adapters/auth.js";
28
28
  import { MediaAdapter, Thumbnailer } from "./adapters/media.js";
29
+ import { ChangeTracker } from "./core/changes.js";
29
30
  import { AddressPolicy } from "./core/outbound.js";
30
31
  import { EventEmitter } from "node:events";
31
32
  //#region src/homeserver.d.ts
@@ -84,7 +85,7 @@ export interface HomeserverOptions {
84
85
  /** Maximum `/sync` long-poll duration in ms. Default: 30000. */
85
86
  maxSyncTimeoutMs?: number;
86
87
  /**
87
- * How many `/sync` responses one process builds at once (default 4). Requests woken together
88
+ * How many `/sync` responses one process builds at once (default 16). Requests woken together
88
89
  * wait in the process instead of queueing their database reads ahead of everything else.
89
90
  */
90
91
  maxConcurrentSyncs?: number;
@@ -176,6 +177,11 @@ export declare class Homeserver extends EventEmitter {
176
177
  readonly serverName: string;
177
178
  readonly config: ResolvedConfig;
178
179
  readonly db: DatabaseAdapter;
180
+ /**
181
+ * Where keys last changed, when this is the only instance on its database (no cluster
182
+ * adapter): /sync skips the reads that would find nothing new (#51). Null in a cluster.
183
+ */
184
+ readonly changes: ChangeTracker | null;
179
185
  readonly media: MediaAdapter;
180
186
  readonly userAuth: ResolvedUserAuth;
181
187
  readonly logger: Logger;
@@ -14,6 +14,7 @@ import { Notifier } from "./core/notifier.js";
14
14
  import { StreamManager, localCluster, newInstanceId } from "./core/cluster.js";
15
15
  import { TypingService } from "./core/typing.js";
16
16
  import { coalesceReads } from "./storage/batch.js";
17
+ import { ChangeTracker } from "./core/changes.js";
17
18
  import { PushService } from "./core/push.js";
18
19
  import { SsoService } from "./core/sso.js";
19
20
  import { AdminService } from "./core/admin.js";
@@ -53,6 +54,11 @@ var Homeserver = class extends EventEmitter {
53
54
  serverName;
54
55
  config;
55
56
  db;
57
+ /**
58
+ * Where keys last changed, when this is the only instance on its database (no cluster
59
+ * adapter): /sync skips the reads that would find nothing new (#51). Null in a cluster.
60
+ */
61
+ changes;
56
62
  media;
57
63
  userAuth;
58
64
  logger;
@@ -115,7 +121,7 @@ var Homeserver = class extends EventEmitter {
115
121
  defaultRoomVersion,
116
122
  accessTokenLifetimeMs: options.accessTokenLifetimeMs ?? null,
117
123
  maxSyncTimeoutMs: options.maxSyncTimeoutMs ?? 3e4,
118
- maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ?? 4),
124
+ maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ?? 16),
119
125
  urlPreviews: options.urlPreviews ? { allowPrivateAddresses: typeof options.urlPreviews === "object" && !!options.urlPreviews.allowPrivateAddresses } : null,
120
126
  thumbnailer: options.thumbnailer ?? null,
121
127
  rtc: options.rtc ?? {},
@@ -125,19 +131,21 @@ var Homeserver = class extends EventEmitter {
125
131
  roomName: options.serverNotices?.roomName ?? "Server Notices"
126
132
  }
127
133
  };
128
- const db = options.databaseAdapter;
129
- if (db && typeof db.findOne === "function") this.db = coalesceReads(db);
130
- else {
134
+ this.changes = options.cluster ? null : new ChangeTracker();
135
+ let db = options.databaseAdapter;
136
+ if (!db || typeof db.findOne !== "function") {
131
137
  this.logger.warn("No database adapter given; using in-memory storage (data is lost on restart)");
132
- this.db = memoryAdapter();
138
+ db = memoryAdapter();
133
139
  }
140
+ this.db = coalesceReads(this.changes ? this.changes.wrap(db) : db);
134
141
  const media = options.mediaAdapter;
135
142
  this.media = media && typeof media.put === "function" ? media : memoryMedia();
136
143
  this.userAuth = resolveUserAuth(options.userAuthAdapter);
137
144
  this.cluster = options.cluster ?? localCluster();
138
145
  this.instanceId = newInstanceId();
139
- this.streams = new StreamManager(this.cluster, this.instanceId);
140
- this.notifier = new Notifier(this.cluster, this.instanceId);
146
+ this.streams = new StreamManager(this.cluster, this.instanceId, (...args) => this.logger.warn(...args));
147
+ this.notifier = new Notifier(this.cluster, this.instanceId, () => this.streams.lastAllocated, (fresh) => this.streams.position(fresh));
148
+ this.streams.onAdvance = () => this.notifier.advanced();
141
149
  this.auth = new AuthService(this);
142
150
  this.rooms = new RoomService(this, !options.cluster);
143
151
  this.keys = new KeyService(this);
@@ -197,6 +205,7 @@ var Homeserver = class extends EventEmitter {
197
205
  }
198
206
  const snap = await this.db.max("state_snapshots", "id");
199
207
  if (snap !== null) await this.streams.advanceTo(snap);
208
+ this.changes?.start(await this.streams.position(true));
200
209
  const oldest = await this.db.findMany("events", {
201
210
  sortBy: {
202
211
  field: "stream",
@@ -207,8 +207,8 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
207
207
  };
208
208
  return build(rootExecutor, false);
209
209
  }
210
- /** How long SQLite statements may run back to back before yielding to the event loop (#47). */
211
- const YIELD_AFTER_MS = 10;
210
+ /** How long SQLite statements may run back to back before yielding to the event loop (#47, #52). */
211
+ const YIELD_AFTER_MS = 50;
212
212
  /** Prepared statements an SQLite adapter keeps (#40). */
213
213
  const STATEMENT_CACHE = 500;
214
214
  /** SQLite adapter for `node:sqlite` (`new DatabaseSync(path)`) or `better-sqlite3`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "matrix-nodejs-homeserver",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "A framework- and database-agnostic Matrix homeserver library for Node.js",
5
5
  "keywords": [
6
6
  "chat",