matrix-nodejs-homeserver 0.3.2 → 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.
- package/README.md +58 -20
- package/dist/api/longpoll.js +61 -0
- package/dist/api/slidingsync.js +30 -32
- package/dist/api/sync.js +331 -183
- package/dist/core/admin.js +1 -0
- package/dist/core/changes.d.ts +79 -0
- package/dist/core/changes.js +332 -0
- package/dist/core/cluster.d.ts +19 -3
- package/dist/core/cluster.js +44 -4
- package/dist/core/keys.js +4 -2
- package/dist/core/notifier.d.ts +49 -3
- package/dist/core/notifier.js +123 -26
- package/dist/core/push.d.ts +12 -11
- package/dist/core/push.js +32 -24
- package/dist/core/pushrules.js +24 -5
- package/dist/core/rooms.js +7 -1
- package/dist/core/semaphore.js +1 -1
- package/dist/homeserver.d.ts +7 -1
- package/dist/homeserver.js +17 -7
- package/dist/storage/batch.js +72 -1
- package/dist/storage/schema.js +7 -2
- package/dist/storage/sql.d.ts +22 -3
- package/dist/storage/sql.js +126 -28
- package/dist/storage/types.d.ts +15 -1
- package/package.json +1 -1
package/dist/core/notifier.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
39
|
+
if (msg?.from !== this.instanceId && Array.isArray(msg?.users)) this.hold(msg.users, Number(msg.pos) || 0);
|
|
15
40
|
});
|
|
16
41
|
}
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
package/dist/core/push.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -3,8 +3,10 @@ import { M, UNSTABLE } from "./m.js";
|
|
|
3
3
|
import { splitStateKey, toClientEvent } from "./events.js";
|
|
4
4
|
import { getState, powerLevels, userLevel } from "./authrules.js";
|
|
5
5
|
import { globMatch, globSearchWords } from "./glob.js";
|
|
6
|
-
import { KINDS,
|
|
6
|
+
import { KINDS, readOnlyRules } from "./pushrules.js";
|
|
7
7
|
import { isSubscribed } from "../api/threadsubs.js";
|
|
8
|
+
import { upsertMany } from "../storage/batch.js";
|
|
9
|
+
import { freeze } from "./changes.js";
|
|
8
10
|
//#region src/core/push.ts
|
|
9
11
|
/** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
|
|
10
12
|
function valueAtPath(obj, path) {
|
|
@@ -84,10 +86,6 @@ function threadIdOf(event) {
|
|
|
84
86
|
const rel = event.content?.[M.RELATES_TO];
|
|
85
87
|
return isObject(rel) && rel.rel_type === M.THREAD && typeof rel.event_id === "string" ? rel.event_id : "main";
|
|
86
88
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Push rule evaluation and push gateway delivery. Every new event is evaluated for
|
|
89
|
-
* every local member; matches are stored for unread counts and `/notifications`.
|
|
90
|
-
*/
|
|
91
89
|
var PushService = class {
|
|
92
90
|
hs;
|
|
93
91
|
constructor(hs) {
|
|
@@ -109,7 +107,7 @@ var PushService = class {
|
|
|
109
107
|
const notified = [];
|
|
110
108
|
for (const userId of members) {
|
|
111
109
|
if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
|
|
112
|
-
const actions = evaluateRules(await
|
|
110
|
+
const actions = evaluateRules(await readOnlyRules(this.hs, userId), {
|
|
113
111
|
event,
|
|
114
112
|
userId,
|
|
115
113
|
displayName: getState(state, M.ROOM.MEMBER, userId)?.content?.displayname ?? null,
|
|
@@ -136,9 +134,7 @@ var PushService = class {
|
|
|
136
134
|
});
|
|
137
135
|
}
|
|
138
136
|
if (notified.length === 0) return;
|
|
139
|
-
await this.hs.transaction(
|
|
140
|
-
for (const n of notified) await db.upsert("notifications", n.row);
|
|
141
|
-
});
|
|
137
|
+
await this.hs.transaction((db) => upsertMany(db, "notifications", notified.map((n) => n.row)));
|
|
142
138
|
const pushers = await this.hs.db.findMany("pushers", { where: [{
|
|
143
139
|
field: "user_id",
|
|
144
140
|
operator: "in",
|
|
@@ -177,23 +173,35 @@ var PushService = class {
|
|
|
177
173
|
return row.stream <= read.unthreaded || row.stream <= (read.threads.get(row.thread_id) ?? 0);
|
|
178
174
|
}
|
|
179
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) {
|
|
180
181
|
const read = await this.readPositions(userId, roomId);
|
|
181
182
|
const unthreaded = read.unthreaded;
|
|
182
|
-
const rows = await this.hs.db.findMany("notifications", {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
183
|
+
const rows = await this.hs.db.findMany("notifications", {
|
|
184
|
+
where: [
|
|
185
|
+
{
|
|
186
|
+
field: "user_id",
|
|
187
|
+
value: userId
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
field: "room_id",
|
|
191
|
+
value: roomId
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
field: "stream",
|
|
195
|
+
operator: "gt",
|
|
196
|
+
value: unthreaded
|
|
197
|
+
}
|
|
198
|
+
],
|
|
199
|
+
fields: [
|
|
200
|
+
"stream",
|
|
201
|
+
"thread_id",
|
|
202
|
+
"highlight"
|
|
203
|
+
]
|
|
204
|
+
});
|
|
197
205
|
const room = {
|
|
198
206
|
notification_count: 0,
|
|
199
207
|
highlight_count: 0
|
package/dist/core/pushrules.js
CHANGED
|
@@ -131,14 +131,33 @@ function cacheFor(hs) {
|
|
|
131
131
|
}
|
|
132
132
|
return c;
|
|
133
133
|
}
|
|
134
|
+
/** A user's push rules, as a copy the caller may change. */
|
|
134
135
|
async function loadRules(hs, userId) {
|
|
136
|
+
return structuredClone(await readOnlyRules(hs, userId));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A user's push rules, shared and frozen: for callers that only read them.
|
|
140
|
+
*
|
|
141
|
+
* https://github.com/k18k/matrix-nodejs-homeserver/issues/41: push evaluation copied every
|
|
142
|
+
* member's rules for every message, about 6% of the CPU under load, though it only reads them.
|
|
143
|
+
* The cached rulesets are deep-frozen, so a caller that tries to change one fails loudly instead
|
|
144
|
+
* of changing everyone's.
|
|
145
|
+
*/
|
|
146
|
+
async function readOnlyRules(hs, userId) {
|
|
135
147
|
const cached = cacheFor(hs).get(userId);
|
|
136
|
-
if (cached) return
|
|
137
|
-
const rules = await readRules(hs, userId);
|
|
148
|
+
if (cached) return cached;
|
|
149
|
+
const rules = deepFreeze(await readRules(hs, userId));
|
|
138
150
|
const cache = cacheFor(hs);
|
|
139
151
|
if (cache.size > 5e3) cache.clear();
|
|
140
152
|
cache.set(userId, rules);
|
|
141
|
-
return
|
|
153
|
+
return rules;
|
|
154
|
+
}
|
|
155
|
+
function deepFreeze(value) {
|
|
156
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
157
|
+
Object.freeze(value);
|
|
158
|
+
for (const v of Object.values(value)) deepFreeze(v);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
142
161
|
}
|
|
143
162
|
async function readRules(hs, userId) {
|
|
144
163
|
const row = await hs.db.findOne("account_data", [
|
|
@@ -162,7 +181,7 @@ async function readRules(hs, userId) {
|
|
|
162
181
|
return rules;
|
|
163
182
|
}
|
|
164
183
|
async function saveRules(hs, userId, rules) {
|
|
165
|
-
cacheFor(hs).set(userId, structuredClone(rules));
|
|
184
|
+
cacheFor(hs).set(userId, deepFreeze(structuredClone(rules)));
|
|
166
185
|
hs.cluster.publish("pushrules", {
|
|
167
186
|
from: hs.instanceId,
|
|
168
187
|
userId
|
|
@@ -226,4 +245,4 @@ async function copyRules(hs, userId, oldRoomId, newRoomId) {
|
|
|
226
245
|
if (changed) await saveRules(hs, userId, rules);
|
|
227
246
|
}
|
|
228
247
|
//#endregion
|
|
229
|
-
export { KINDS, copyRoomPushRules, defaultRules, loadRules, saveRules, withRulesLock };
|
|
248
|
+
export { KINDS, copyRoomPushRules, defaultRules, loadRules, readOnlyRules, saveRules, withRulesLock };
|
package/dist/core/rooms.js
CHANGED
|
@@ -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
|
};
|
package/dist/core/semaphore.js
CHANGED
package/dist/homeserver.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/homeserver.js
CHANGED
|
@@ -13,6 +13,8 @@ import { KeyService } from "./core/keys.js";
|
|
|
13
13
|
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
|
+
import { coalesceReads } from "./storage/batch.js";
|
|
17
|
+
import { ChangeTracker } from "./core/changes.js";
|
|
16
18
|
import { PushService } from "./core/push.js";
|
|
17
19
|
import { SsoService } from "./core/sso.js";
|
|
18
20
|
import { AdminService } from "./core/admin.js";
|
|
@@ -52,6 +54,11 @@ var Homeserver = class extends EventEmitter {
|
|
|
52
54
|
serverName;
|
|
53
55
|
config;
|
|
54
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;
|
|
55
62
|
media;
|
|
56
63
|
userAuth;
|
|
57
64
|
logger;
|
|
@@ -114,7 +121,7 @@ var Homeserver = class extends EventEmitter {
|
|
|
114
121
|
defaultRoomVersion,
|
|
115
122
|
accessTokenLifetimeMs: options.accessTokenLifetimeMs ?? null,
|
|
116
123
|
maxSyncTimeoutMs: options.maxSyncTimeoutMs ?? 3e4,
|
|
117
|
-
maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ??
|
|
124
|
+
maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ?? 16),
|
|
118
125
|
urlPreviews: options.urlPreviews ? { allowPrivateAddresses: typeof options.urlPreviews === "object" && !!options.urlPreviews.allowPrivateAddresses } : null,
|
|
119
126
|
thumbnailer: options.thumbnailer ?? null,
|
|
120
127
|
rtc: options.rtc ?? {},
|
|
@@ -124,19 +131,21 @@ var Homeserver = class extends EventEmitter {
|
|
|
124
131
|
roomName: options.serverNotices?.roomName ?? "Server Notices"
|
|
125
132
|
}
|
|
126
133
|
};
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
134
|
+
this.changes = options.cluster ? null : new ChangeTracker();
|
|
135
|
+
let db = options.databaseAdapter;
|
|
136
|
+
if (!db || typeof db.findOne !== "function") {
|
|
130
137
|
this.logger.warn("No database adapter given; using in-memory storage (data is lost on restart)");
|
|
131
|
-
|
|
138
|
+
db = memoryAdapter();
|
|
132
139
|
}
|
|
140
|
+
this.db = coalesceReads(this.changes ? this.changes.wrap(db) : db);
|
|
133
141
|
const media = options.mediaAdapter;
|
|
134
142
|
this.media = media && typeof media.put === "function" ? media : memoryMedia();
|
|
135
143
|
this.userAuth = resolveUserAuth(options.userAuthAdapter);
|
|
136
144
|
this.cluster = options.cluster ?? localCluster();
|
|
137
145
|
this.instanceId = newInstanceId();
|
|
138
|
-
this.streams = new StreamManager(this.cluster, this.instanceId);
|
|
139
|
-
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();
|
|
140
149
|
this.auth = new AuthService(this);
|
|
141
150
|
this.rooms = new RoomService(this, !options.cluster);
|
|
142
151
|
this.keys = new KeyService(this);
|
|
@@ -196,6 +205,7 @@ var Homeserver = class extends EventEmitter {
|
|
|
196
205
|
}
|
|
197
206
|
const snap = await this.db.max("state_snapshots", "id");
|
|
198
207
|
if (snap !== null) await this.streams.advanceTo(snap);
|
|
208
|
+
this.changes?.start(await this.streams.position(true));
|
|
199
209
|
const oldest = await this.db.findMany("events", {
|
|
200
210
|
sortBy: {
|
|
201
211
|
field: "stream",
|
package/dist/storage/batch.js
CHANGED
|
@@ -7,5 +7,76 @@ function readBatch(db, queries) {
|
|
|
7
7
|
if (db.findManyBatch) return db.findManyBatch(queries);
|
|
8
8
|
return Promise.all(queries.map((query) => db.findMany(query.model, query.opts)));
|
|
9
9
|
}
|
|
10
|
+
/** Upsert several rows: in a few statements when the adapter can (`upsertMany`), otherwise one by one. */
|
|
11
|
+
async function upsertMany(db, model, rows) {
|
|
12
|
+
if (rows.length === 0) return;
|
|
13
|
+
if (db.upsertMany) return db.upsertMany(model, rows);
|
|
14
|
+
for (const row of rows) await db.upsert(model, row);
|
|
15
|
+
}
|
|
16
|
+
/** At most this many reads go into one coalesced statement. */
|
|
17
|
+
const MAX_COALESCED = 32;
|
|
18
|
+
/**
|
|
19
|
+
* An adapter whose reads (`findMany`, `findOne`) issued within one turn of the event loop are
|
|
20
|
+
* sent together, through `findManyBatch`. Writes and transactions go straight to `db`. Adapters
|
|
21
|
+
* without `findManyBatch` are returned as they are.
|
|
22
|
+
*
|
|
23
|
+
* https://github.com/k18k/matrix-nodejs-homeserver/issues/43: under load, most of the homeserver's
|
|
24
|
+
* and the database's CPU went to the per-statement cost (a socket write, a round trip, parsing)
|
|
25
|
+
* of many small reads issued at the same moment by concurrent requests. Nothing is kept between
|
|
26
|
+
* turns: this merges requests, it does not cache results. A read waits for at most the rest of
|
|
27
|
+
* the current turn. If a coalesced statement fails, its reads are run one by one, so a bad read
|
|
28
|
+
* fails alone.
|
|
29
|
+
*/
|
|
30
|
+
function coalesceReads(db) {
|
|
31
|
+
if (!db.findManyBatch) return db;
|
|
32
|
+
let queue = [];
|
|
33
|
+
const runAlone = (read) => db.findMany(read.query.model, read.query.opts).then(read.resolve, read.reject);
|
|
34
|
+
const run = async (reads) => {
|
|
35
|
+
if (reads.length === 1) return runAlone(reads[0]);
|
|
36
|
+
let results;
|
|
37
|
+
try {
|
|
38
|
+
results = await db.findManyBatch(reads.map((r) => r.query), { prepare: false });
|
|
39
|
+
} catch {
|
|
40
|
+
for (const read of reads) runAlone(read);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
reads.forEach((read, i) => read.resolve(results[i]));
|
|
44
|
+
};
|
|
45
|
+
const flush = () => {
|
|
46
|
+
const reads = queue;
|
|
47
|
+
queue = [];
|
|
48
|
+
for (let i = 0; i < reads.length; i += MAX_COALESCED) run(reads.slice(i, i + MAX_COALESCED));
|
|
49
|
+
};
|
|
50
|
+
const enqueue = (query) => new Promise((resolve, reject) => {
|
|
51
|
+
if (queue.length === 0) setImmediate(flush);
|
|
52
|
+
queue.push({
|
|
53
|
+
query,
|
|
54
|
+
resolve,
|
|
55
|
+
reject
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
findMany: (model, opts) => enqueue({
|
|
60
|
+
model,
|
|
61
|
+
opts
|
|
62
|
+
}),
|
|
63
|
+
findOne: async (model, where) => (await enqueue({
|
|
64
|
+
model,
|
|
65
|
+
opts: {
|
|
66
|
+
where,
|
|
67
|
+
limit: 1
|
|
68
|
+
}
|
|
69
|
+
}))[0] ?? null,
|
|
70
|
+
findManyBatch: (queries, options) => db.findManyBatch(queries, options),
|
|
71
|
+
create: (model, data) => db.create(model, data),
|
|
72
|
+
upsert: (model, data) => db.upsert(model, data),
|
|
73
|
+
...db.upsertMany ? { upsertMany: (model, rows) => db.upsertMany(model, rows) } : {},
|
|
74
|
+
update: (model, where, data) => db.update(model, where, data),
|
|
75
|
+
delete: (model, where) => db.delete(model, where),
|
|
76
|
+
max: (model, field) => db.max(model, field),
|
|
77
|
+
...db.migrate ? { migrate: (schema) => db.migrate(schema) } : {},
|
|
78
|
+
...db.transaction ? { transaction: (fn) => db.transaction(fn) } : {}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
10
81
|
//#endregion
|
|
11
|
-
export { readBatch };
|
|
82
|
+
export { coalesceReads, readBatch, upsertMany };
|
package/dist/storage/schema.js
CHANGED
|
@@ -192,7 +192,11 @@ const schema = {
|
|
|
192
192
|
ts: n
|
|
193
193
|
},
|
|
194
194
|
primaryKey: ["user_id", "event_id"],
|
|
195
|
-
indexes: [[
|
|
195
|
+
indexes: [[
|
|
196
|
+
"user_id",
|
|
197
|
+
"room_id",
|
|
198
|
+
"stream"
|
|
199
|
+
], ["user_id", "stream"]]
|
|
196
200
|
},
|
|
197
201
|
sso_identities: {
|
|
198
202
|
fields: {
|
|
@@ -397,7 +401,8 @@ const schema = {
|
|
|
397
401
|
last_active_ts: n,
|
|
398
402
|
stream: n
|
|
399
403
|
},
|
|
400
|
-
primaryKey: ["user_id"]
|
|
404
|
+
primaryKey: ["user_id"],
|
|
405
|
+
indexes: [["stream"]]
|
|
401
406
|
},
|
|
402
407
|
media: {
|
|
403
408
|
fields: {
|
package/dist/storage/sql.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { DatabaseAdapter, Row } from "./types.js";
|
|
|
2
2
|
//#region src/storage/sql.d.ts
|
|
3
3
|
export type SqlDialect = "sqlite" | "postgres" | "mysql";
|
|
4
4
|
export interface SqlExecutor {
|
|
5
|
-
/** Run a query that returns rows. */
|
|
6
|
-
query(sql: string, params: unknown[]
|
|
5
|
+
/** Run a query that returns rows; `prepare: false` when the statement is not worth preparing for reuse. */
|
|
6
|
+
query(sql: string, params: unknown[], options?: {
|
|
7
|
+
prepare?: boolean;
|
|
8
|
+
}): Promise<Row[]>;
|
|
7
9
|
/** Run a statement; resolve with the number of affected rows. */
|
|
8
10
|
execute(sql: string, params: unknown[]): Promise<number>;
|
|
9
11
|
}
|
|
@@ -36,9 +38,20 @@ export interface SqliteDatabaseLike {
|
|
|
36
38
|
/** SQLite adapter for `node:sqlite` (`new DatabaseSync(path)`) or `better-sqlite3`. */
|
|
37
39
|
export declare function sqliteAdapter(db: SqliteDatabaseLike, options?: {
|
|
38
40
|
tablePrefix?: string;
|
|
41
|
+
/**
|
|
42
|
+
* When commits reach the disk (SQLite's `synchronous`). `"normal"` (the default): at
|
|
43
|
+
* checkpoints; a commit survives a crash of the process, and a power loss or operating
|
|
44
|
+
* system crash can roll back the most recent ones, without corrupting the database.
|
|
45
|
+
* `"full"`: on every commit, which blocks the event loop for an fsync each time.
|
|
46
|
+
*/
|
|
47
|
+
synchronous?: "normal" | "full";
|
|
39
48
|
}): DatabaseAdapter;
|
|
40
49
|
interface PgQueryable {
|
|
41
|
-
query(
|
|
50
|
+
query(config: {
|
|
51
|
+
text: string;
|
|
52
|
+
values?: unknown[];
|
|
53
|
+
name?: string;
|
|
54
|
+
}): Promise<{
|
|
42
55
|
rows: Row[];
|
|
43
56
|
rowCount: number | null;
|
|
44
57
|
}>;
|
|
@@ -53,6 +66,12 @@ export interface PgPoolLike extends PgQueryable {
|
|
|
53
66
|
/** PostgreSQL adapter for `pg` (node-postgres). Pass a `Pool` to get transactions. */
|
|
54
67
|
export declare function postgresAdapter(pool: PgPoolLike, options?: {
|
|
55
68
|
tablePrefix?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Prepare statements on each connection and reuse them (default true). Turn off behind a
|
|
71
|
+
* connection pooler that does not support prepared statements, such as PgBouncer before 1.21
|
|
72
|
+
* or in transaction mode without `max_prepared_statements`.
|
|
73
|
+
*/
|
|
74
|
+
preparedStatements?: boolean;
|
|
56
75
|
}): DatabaseAdapter;
|
|
57
76
|
interface MysqlQueryable {
|
|
58
77
|
query(sql: string, values?: unknown[]): Promise<[any, unknown]>;
|