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/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# matrix-nodejs-homeserver
|
|
2
2
|
|
|
3
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/ci.yml)
|
|
3
4
|
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
4
5
|
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
5
6
|
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
6
|
-
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
7
8
|
|
|
8
9
|
A [Matrix](https://matrix.org) homeserver as an importable Node.js library. It isn't
|
|
9
10
|
tied to any HTTP framework, database, auth system or deployment shape. You can use
|
|
@@ -65,12 +66,21 @@ mysqlAdapter(mysql.createPool(uri)); // mysql2/promise
|
|
|
65
66
|
memoryAdapter(); // tests / demos
|
|
66
67
|
```
|
|
67
68
|
|
|
69
|
+
SQLite runs in WAL mode with `synchronous = NORMAL`: a commit survives a crash of the process,
|
|
70
|
+
but a power loss can roll back the last few. Pass `sqliteAdapter(db, { synchronous: "full" })`
|
|
71
|
+
to wait for the disk on every commit, at the cost of blocking the event loop each time.
|
|
72
|
+
|
|
68
73
|
Tables are created on startup. Writes for each event are atomic: SQLite uses
|
|
69
74
|
`BEGIN IMMEDIATE`, Postgres and MySQL use a dedicated pooled connection, and the
|
|
70
75
|
memory adapter keeps an undo log.
|
|
71
76
|
|
|
77
|
+
The Postgres adapter prepares its statements on each connection and reuses them. Behind a
|
|
78
|
+
pooler that does not support prepared statements (PgBouncer before 1.21, or in transaction
|
|
79
|
+
mode without `max_prepared_statements`), pass `postgresAdapter(pool, { preparedStatements: false })`.
|
|
80
|
+
|
|
72
81
|
To use another store, implement `DatabaseAdapter` (`create`, `upsert`, `findOne`,
|
|
73
|
-
`findMany`, `update`, `delete`, `max`, and optionally `migrate
|
|
82
|
+
`findMany`, `update`, `delete`, `max`, and optionally `migrate`, `transaction`,
|
|
83
|
+
`findManyBatch` and `upsertMany`).
|
|
74
84
|
The exported `schema` describes every model. If your driver can run parameterised
|
|
75
85
|
SQL, `sqlAdapter({ dialect, executor, transaction })` handles the SQL generation for you.
|
|
76
86
|
|
|
@@ -459,6 +469,7 @@ Live status, updated after every Complement run (full or targeted) with
|
|
|
459
469
|
|
|
460
470
|
| Run | Top-level | Subtests | Note |
|
|
461
471
|
|---|---|---|---|
|
|
472
|
+
| 10 | 214 / 214 | 851 / 851 | load-test fixes #25–#35: batched /sync reads, sync concurrency limit, Postgres lock leases, group commit (248ac8d) |
|
|
462
473
|
| 9 | 214 / 214 | 851 / 851 | zod, procedures, M/UNSTABLE, SSO confirmation, outbound address policy, admin guard (ec5aa79) |
|
|
463
474
|
| 8 | 214 / 214 | 851 / 851 | Complement runs in a Go container (no submodule, no local Go); account locking/suspension, policy servers, 3PIDs, legacy sync endpoints |
|
|
464
475
|
| 7 | 214 / 214 | 851 / 851 | All Complement tests pass (previous full run: 211/214). Fallback keys stored apart from one-time keys; user directory public-room rules; MSC4311 create event required from v12 |
|
|
@@ -505,26 +516,53 @@ BASE_URL=http://localhost:8008 ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25
|
|
|
505
516
|
|
|
506
517
|
<!-- loadtest-results:start -->
|
|
507
518
|
The badges at the top come from the [Load test workflow](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml) on main: the same
|
|
508
|
-
scenario on one GitHub runner (4 CPUs), with k6 on the same machine
|
|
519
|
+
scenario on one GitHub runner (4 CPUs), with k6 on the same machine, and clusters of two nodes
|
|
520
|
+
(five nodes would compete with Postgres and k6 for four CPUs). Each run's job summary has
|
|
509
521
|
the full table.
|
|
510
522
|
|
|
511
|
-
Latest results
|
|
512
|
-
30 s
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
523
|
+
Latest results, on one developer machine (WSL2, Docker Desktop): 1000 users online in 25 rooms
|
|
524
|
+
(40 per room), 30 s ramp-up, then 30 s of messages. Times are p50 / p95; "sent" is the share of
|
|
525
|
+
the messages the scenario asks for that the server accepted (a server that falls behind takes
|
|
526
|
+
fewer, and then looks faster than it is).
|
|
527
|
+
|
|
528
|
+
At 10 messages/s (every user receiving one message every 2.5 s):
|
|
529
|
+
|
|
530
|
+
| Setup | send | delivery | sent | failed |
|
|
531
|
+
|---|---|---|---|---|
|
|
532
|
+
| SQLite, 1 process | 5 ms / 15 ms | 14 ms / 0.18 s | 100% | 0% |
|
|
533
|
+
| Postgres, no cluster | 9 ms / 30 ms | 29 ms / 0.60 s | 100% | 0% |
|
|
534
|
+
|
|
535
|
+
At 50 messages/s (every user receiving two messages a second, 59,000 deliveries in 30 s):
|
|
536
|
+
|
|
537
|
+
| Setup | req/s | send | /sync | delivery | sent | failed |
|
|
538
|
+
|---|---|---|---|---|---|---|
|
|
539
|
+
| SQLite, 1 process | 722 | 11 ms / 25 ms | 0.77 s / 1.3 s | 1.2 s / 1.6 s | 100% | 0% |
|
|
540
|
+
| Postgres, no cluster | 687 | 35 ms / 49 ms | 0.79 s / 1.2 s | 1.2 s / 1.6 s | 100% | 0% |
|
|
541
|
+
| Postgres, cluster, 1 node | 766 | 72 ms / 98 ms | 1.1 s / 1.5 s | 1.7 s / 2.2 s | 100% | 0% |
|
|
542
|
+
| Postgres, cluster, 5 nodes | 910 | 146 ms / 233 ms | 0.42 s / 1.6 s | 0.37 s / 1.5 s | 100% | 0% |
|
|
543
|
+
|
|
544
|
+
At 50 messages/s, delivery time is how long a process takes to rebuild the `/sync` of every
|
|
545
|
+
user in turn, since every user has something new in nearly every rebuild.
|
|
546
|
+
|
|
547
|
+
What the load tests found and fixed:
|
|
548
|
+
- presence written on every `/sync` (#26), one commit per push notification (#27), a missing
|
|
549
|
+
index (#28), Postgres cluster locks that could deadlock the pool (#29), stream writes
|
|
550
|
+
serialized per instance (#30), `/sync` reading every change on the server (#31) and every
|
|
551
|
+
joined room (#33), lock handover by polling (#32), unbatched `/sync` reads (#34), syncs
|
|
552
|
+
crowding out sends (#35);
|
|
553
|
+
- in 0.3.3 (#38–#47): rooms built one after another (#38), a statement per push notification
|
|
554
|
+
(#39), Postgres re-planning every statement (#40), push rules copied per member (#41),
|
|
555
|
+
needless to-device deletes (#42), concurrent reads as separate statements (#43), presence and
|
|
556
|
+
device lists looked up per user (#44), costly batched reads (#45), an fsync per SQLite commit
|
|
557
|
+
(#46), a blocked event loop (#47);
|
|
558
|
+
- since 0.3.3: waiting `/sync` requests rebuilt whenever anything on the server changed, and
|
|
559
|
+
about two thirds of all rebuilds found nothing new (#50); a single instance now keeps track of
|
|
560
|
+
where data last changed, so `/sync` skips reads that would find nothing, like Synapse's stream
|
|
561
|
+
change caches (#51); queued syncs held the event loop, and yielding per statement made sends
|
|
562
|
+
slow (#52).
|
|
563
|
+
|
|
564
|
+
The load test itself was fixed too: its senders registered during the measurement, so a slow
|
|
565
|
+
server was sent fewer messages, and nginx could start after k6 (a red badge with "100% failed").
|
|
528
566
|
<!-- loadtest-results:end -->
|
|
529
567
|
|
|
530
568
|
## License
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//#region src/api/longpoll.ts
|
|
2
|
+
/**
|
|
3
|
+
* The long-poll loop of `/sync` and sliding sync: build a response up to the current stream
|
|
4
|
+
* position, and while it has nothing new, wait for a change for `userId` and build again.
|
|
5
|
+
*
|
|
6
|
+
* https://github.com/k18k/matrix-nodejs-homeserver/issues/50: after a build with nothing new, the
|
|
7
|
+
* loop used to build again whenever the server-wide position had moved, which under load is
|
|
8
|
+
* always, so waiting requests rebuilt back to back. Now it registers for the user's wake-ups
|
|
9
|
+
* before reading the position, so none is missed during a build, and otherwise sleeps. A wake-up
|
|
10
|
+
* says which position its change has; if the next build cannot see it yet (another write in
|
|
11
|
+
* flight holds the position back), the loop waits for the position to get there.
|
|
12
|
+
*
|
|
13
|
+
* `build(to)` returns whether the response is final (`done`); returns the last built value, the
|
|
14
|
+
* position it was built at, and whether the request was aborted while waiting.
|
|
15
|
+
*/
|
|
16
|
+
async function longPoll(hs, userId, timeoutMs, signal, build) {
|
|
17
|
+
const deadline = Date.now() + timeoutMs;
|
|
18
|
+
let pending = 0;
|
|
19
|
+
for (;;) {
|
|
20
|
+
const listener = hs.notifier.listen(userId);
|
|
21
|
+
pending = Math.max(pending, listener.recent);
|
|
22
|
+
let to;
|
|
23
|
+
let built;
|
|
24
|
+
try {
|
|
25
|
+
to = await hs.streams.position();
|
|
26
|
+
if (pending > to) to = await hs.streams.position(true);
|
|
27
|
+
built = await build(to);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
listener.cancel();
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
if (pending <= to) pending = 0;
|
|
33
|
+
const remaining = deadline - Date.now();
|
|
34
|
+
if (built.done || remaining <= 0 || signal?.aborted) {
|
|
35
|
+
listener.cancel();
|
|
36
|
+
return {
|
|
37
|
+
value: built.value,
|
|
38
|
+
to,
|
|
39
|
+
aborted: !!signal?.aborted
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (pending > to) {
|
|
43
|
+
listener.cancel();
|
|
44
|
+
if (await hs.streams.waitFor(pending, remaining, signal)) continue;
|
|
45
|
+
return {
|
|
46
|
+
value: built.value,
|
|
47
|
+
to,
|
|
48
|
+
aborted: !!signal?.aborted
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const woke = await listener.wait(remaining, signal);
|
|
52
|
+
if (woke === null) return {
|
|
53
|
+
value: built.value,
|
|
54
|
+
to,
|
|
55
|
+
aborted: !!signal?.aborted
|
|
56
|
+
};
|
|
57
|
+
pending = Math.max(pending, woke);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { longPoll };
|
package/dist/api/slidingsync.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getState } from "../core/authrules.js";
|
|
|
6
6
|
import { record, string, unknown } from "../vendor/zod/v4/classic/schemas.js";
|
|
7
7
|
import { body, lenient } from "../http/input.js";
|
|
8
8
|
import { procedure } from "../http/procedure.js";
|
|
9
|
+
import { longPoll } from "./longpoll.js";
|
|
9
10
|
import { roomEphemeral, roomTimeline, toStripped } from "./sync.js";
|
|
10
11
|
//#region src/api/slidingsync.ts
|
|
11
12
|
const slidingSyncBody = body({
|
|
@@ -104,37 +105,34 @@ async function slidingSync(ctx) {
|
|
|
104
105
|
rooms: {}
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (since === null || !empty || remaining <= 0) {
|
|
113
|
-
await hs.db.upsert("sliding_sync_connections", {
|
|
114
|
-
user_id: s.userId,
|
|
115
|
-
device_id: s.deviceId,
|
|
116
|
-
conn_id: connId,
|
|
117
|
-
pos: to,
|
|
118
|
-
state: {
|
|
119
|
-
...next,
|
|
120
|
-
prevPos: since
|
|
121
|
-
},
|
|
122
|
-
updated_ts: Date.now()
|
|
123
|
-
});
|
|
124
|
-
return {
|
|
125
|
-
pos: String(to),
|
|
126
|
-
...response
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
if (await hs.streams.position() > to) continue;
|
|
130
|
-
await hs.notifier.wait(s.userId, remaining, ctx.request.signal);
|
|
131
|
-
if (ctx.request.signal?.aborted) return {
|
|
132
|
-
pos: String(since),
|
|
133
|
-
lists: {},
|
|
134
|
-
rooms: {},
|
|
135
|
-
extensions: {}
|
|
108
|
+
const { value, to, aborted } = await longPoll(hs, s.userId, timeout, ctx.request.signal, async (to) => {
|
|
109
|
+
const built = await buildResponse(hs, s, body, conn, since, to);
|
|
110
|
+
return {
|
|
111
|
+
value: built,
|
|
112
|
+
done: since === null || !built.empty
|
|
136
113
|
};
|
|
137
|
-
}
|
|
114
|
+
});
|
|
115
|
+
if (aborted && value.empty) return {
|
|
116
|
+
pos: String(since),
|
|
117
|
+
lists: {},
|
|
118
|
+
rooms: {},
|
|
119
|
+
extensions: {}
|
|
120
|
+
};
|
|
121
|
+
await hs.db.upsert("sliding_sync_connections", {
|
|
122
|
+
user_id: s.userId,
|
|
123
|
+
device_id: s.deviceId,
|
|
124
|
+
conn_id: connId,
|
|
125
|
+
pos: to,
|
|
126
|
+
state: {
|
|
127
|
+
...value.next,
|
|
128
|
+
prevPos: since
|
|
129
|
+
},
|
|
130
|
+
updated_ts: Date.now()
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
pos: String(to),
|
|
134
|
+
...value.response
|
|
135
|
+
};
|
|
138
136
|
}
|
|
139
137
|
async function buildResponse(hs, s, body, conn, since, to) {
|
|
140
138
|
const userId = s.userId;
|
|
@@ -421,10 +419,10 @@ async function buildExtensions(hs, s, ext, since, to, roomIds, next) {
|
|
|
421
419
|
else if (roomIds.includes(r.room_id) || since !== null) (rooms[r.room_id] ??= []).push(ev);
|
|
422
420
|
}
|
|
423
421
|
if (since === null && !global.some((e) => e.type === M.PUSH_RULES)) {
|
|
424
|
-
const {
|
|
422
|
+
const { readOnlyRules } = await import("../core/pushrules.js");
|
|
425
423
|
global.push({
|
|
426
424
|
type: M.PUSH_RULES,
|
|
427
|
-
content: { global: await
|
|
425
|
+
content: { global: await readOnlyRules(hs, userId) }
|
|
428
426
|
});
|
|
429
427
|
}
|
|
430
428
|
out.account_data = {
|