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 CHANGED
@@ -1,9 +1,10 @@
1
1
  # matrix-nodejs-homeserver
2
2
 
3
+ [![Complement](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Fcomplement-results%2Fbadges%2Fcomplement.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/ci.yml)
3
4
  [![Load test: SQLite](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fsqlite.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
4
5
  [![Load test: Postgres](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-nocluster.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
5
6
  [![Load test: Postgres cluster, 1 node](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-1.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
6
- [![Load test: Postgres cluster, 5 nodes](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-5.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
7
+ [![Load test: Postgres cluster, 2 nodes](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-2.json)](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` and `transaction`).
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. Each run's job summary has
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: 1000 users online in 25 rooms (40 per room), 50 messages/s, 30 s ramp-up then
512
- 30 s at full load, on one developer machine (WSL2, Docker Desktop). Times are p50 / p95.
513
-
514
- | Setup | req/s | send | /sync | join | delivery | messages delivered | failed |
515
- |---|---|---|---|---|---|---|---|
516
- | SQLite, 1 process (before #28–#35) | ~800 | 46 ms / 0.69 s | 0.50 s / 2.4 s | 61 ms / 0.15 s | 0.58 s / 0.78 s | 5,816 | 0% |
517
- | Postgres, no cluster | 404 | 49 ms / 71 ms | 2.6 s / 3.6 s | 18 ms / 27 ms | 4.6 s / 6.5 s | 56,297 | 0% |
518
- | Postgres, cluster, 1 node | 314 | 81 ms / 124 ms | 2.8 s / 4.4 s | 50 ms / 70 ms | 5.2 s / 7.5 s | 52,071 | 0% |
519
- | Postgres, cluster, 5 nodes | 840 | 118 ms / 197 ms | 0.90 s / 1.4 s | 86 ms / 151 ms | 1.6 s / 2.3 s | 58,298 | 0% |
520
-
521
- What the load tests found and fixed so far: presence written on every `/sync` (#26), one commit
522
- per push notification (#27), a missing index (#28), Postgres cluster locks that could deadlock
523
- the pool (#29), stream writes serialized per instance (#30), `/sync` reading every change on the
524
- server (#31) and every joined room (#33), lock handover by polling (#32). Then `/sync` reads were batched
525
- (#34) and a process builds at most four sync responses at once (#35), so sends no longer queue
526
- behind the syncs a message wakes. A Postgres node now builds ~330 syncs/s here; delivery time is
527
- syncs waiting for their turn, and more nodes shorten it.
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 };
@@ -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 deadline = Date.now() + timeout;
108
- for (;;) {
109
- const to = await hs.streams.position();
110
- const { response, empty, next } = await buildResponse(hs, s, body, conn, since, to);
111
- const remaining = deadline - Date.now();
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 { loadRules } = await import("../core/pushrules.js");
422
+ const { readOnlyRules } = await import("../core/pushrules.js");
425
423
  global.push({
426
424
  type: M.PUSH_RULES,
427
- content: { global: await loadRules(hs, userId) }
425
+ content: { global: await readOnlyRules(hs, userId) }
428
426
  });
429
427
  }
430
428
  out.account_data = {