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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
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)
5
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)
6
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)
7
- [![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)
8
8
 
9
9
  A [Matrix](https://matrix.org) homeserver as an importable Node.js library. It isn't
10
10
  tied to any HTTP framework, database, auth system or deployment shape. You can use
@@ -516,42 +516,53 @@ BASE_URL=http://localhost:8008 ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25
516
516
 
517
517
  <!-- loadtest-results:start -->
518
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
519
- 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
520
521
  the full table.
521
522
 
522
- Latest results: 1000 users online in 25 rooms (40 per room), 50 messages/s, 30 s ramp-up then
523
- 30 s at full load, on one developer machine (WSL2, Docker Desktop). Times are p50 / p95.
524
-
525
- | Setup | req/s | send | /sync | join | delivery | messages delivered | failed |
526
- |---|---|---|---|---|---|---|---|
527
- | SQLite, 1 process | 662 | 97 ms / 0.16 s | 0.87 s / 1.6 s | 10 ms / 91 ms | 1.64 s / 2.70 s | 53,935 | 0% |
528
- | Postgres, no cluster | 670 | 15 ms / 19 ms | 1.1 s / 1.8 s | 14 ms / 23 ms | 1.76 s / 2.31 s | 58,667 | 0% |
529
- | Postgres, cluster, 1 node | 593 | 27 ms / 38 ms | 1.3 s / 2.0 s | 36 ms / 51 ms | 1.96 s / 2.57 s | 58,493 | 0% |
530
- | Postgres, cluster, 5 nodes | 871 | 60 ms / 88 ms | 0.50 s / 1.1 s | 62 ms / 119 ms | 0.70 s / 1.09 s | 59,298 | 0% |
531
-
532
- What the load tests found and fixed so far: presence written on every `/sync` (#26), one commit
533
- per push notification (#27), a missing index (#28), Postgres cluster locks that could deadlock
534
- the pool (#29), stream writes serialized per instance (#30), `/sync` reading every change on the
535
- server (#31) and every joined room (#33), lock handover by polling (#32). Then `/sync` reads were batched
536
- (#34) and a process builds at most four sync responses at once (#35), so sends no longer queue
537
- behind the syncs a message wakes.
538
-
539
- Since 0.3.2 (#38–#45), Postgres delivery time fell by about 55% on one node and 50% on five:
540
- - changed rooms are built concurrently, with fewer reads each (#38);
541
- - push notifications of a message are written in one statement (#39);
542
- - Postgres statements are prepared once per connection (#40);
543
- - push rules are no longer copied per member (#41);
544
- - to-device acknowledgements run only when needed (#42);
545
- - reads issued at the same moment by concurrent requests share one statement (#43);
546
- - presence and device-list changes are found through a stream index (#44);
547
- - batched reads take half as long to plan (#45).
548
-
549
- On SQLite, commits no longer wait for an fsync (#46). Database calls also yield to the event
550
- loop (#47): before that, a wave of syncs could hold it for seconds, sends took 0.74 s and the
551
- server took in only about 40% of the messages the scenario sends.
552
-
553
- With every user receiving about two messages a second, delivery time is how long it takes a
554
- process to rebuild every user's `/sync` in turn: about 750 per second per Postgres node here.
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").
555
566
  <!-- loadtest-results:end -->
556
567
 
557
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;
package/dist/api/sync.js CHANGED
@@ -8,7 +8,9 @@ import { deviceListChangesQuery } from "../core/keys.js";
8
8
  import { body } from "../http/input.js";
9
9
  import { procedure } from "../http/procedure.js";
10
10
  import { readBatch } from "../storage/batch.js";
11
+ import { trackedReads } from "../core/changes.js";
11
12
  import { matchesFilter, roomAllowed, validateSyncFilter } from "./filters.js";
13
+ import { longPoll } from "./longpoll.js";
12
14
  import { Semaphore } from "../core/semaphore.js";
13
15
  import { fullProfile } from "../core/profile.js";
14
16
  //#region src/api/sync.ts
@@ -330,6 +332,8 @@ async function changesSince(hs, p, memberships, since, accountRows) {
330
332
  operator: "lte",
331
333
  value: p.to
332
334
  }];
335
+ const logs = hs.changes?.logs;
336
+ const mayHaveChanged = (log, rooms) => logs ? logs[log].filterChanged(rooms, since) : rooms;
333
337
  const chunks = (list) => Array.from({ length: Math.ceil(list.length / 500) }, (_, i) => list.slice(i * 500, i * 500 + 500));
334
338
  const inRooms = (chunk) => ({
335
339
  field: "room_id",
@@ -337,31 +341,34 @@ async function changesSince(hs, p, memberships, since, accountRows) {
337
341
  value: chunk
338
342
  });
339
343
  const queries = [];
340
- for (const chunk of chunks(joined)) {
341
- queries.push({
342
- model: "events",
343
- opts: {
344
- where: [inRooms(chunk), ...range],
345
- fields: ["room_id"]
346
- }
347
- });
348
- queries.push({
349
- model: "receipts",
350
- opts: { where: [inRooms(chunk), ...range] }
351
- });
352
- queries.push({
353
- model: "partial_state_rooms",
354
- opts: {
355
- where: [inRooms(chunk), {
356
- field: "completed_stream",
357
- operator: "gt",
358
- value: since
359
- }],
360
- fields: ["room_id"]
361
- }
362
- });
363
- }
364
- for (const chunk of chunks(anyMembership)) queries.push({
344
+ const kinds = [];
345
+ const add = (kind, query) => {
346
+ kinds.push(kind);
347
+ queries.push(query);
348
+ };
349
+ for (const chunk of chunks(mayHaveChanged("roomEvents", joined))) add("events", {
350
+ model: "events",
351
+ opts: {
352
+ where: [inRooms(chunk), ...range],
353
+ fields: ["room_id"]
354
+ }
355
+ });
356
+ for (const chunk of chunks(mayHaveChanged("roomReceipts", joined))) add("receipts", {
357
+ model: "receipts",
358
+ opts: { where: [inRooms(chunk), ...range] }
359
+ });
360
+ for (const chunk of chunks(mayHaveChanged("roomPartialState", joined))) add("completed", {
361
+ model: "partial_state_rooms",
362
+ opts: {
363
+ where: [inRooms(chunk), {
364
+ field: "completed_stream",
365
+ operator: "gt",
366
+ value: since
367
+ }],
368
+ fields: ["room_id"]
369
+ }
370
+ });
371
+ for (const chunk of chunks(mayHaveChanged("roomMembers", anyMembership))) add("members", {
365
372
  model: "memberships",
366
373
  opts: { where: [inRooms(chunk), {
367
374
  field: "stream",
@@ -369,20 +376,18 @@ async function changesSince(hs, p, memberships, since, accountRows) {
369
376
  value: since
370
377
  }] }
371
378
  });
372
- const results = await readBatch(hs.db, queries);
379
+ const results = queries.length ? await readBatch(hs.db, queries) : [];
373
380
  const touched = /* @__PURE__ */ new Set();
374
381
  const receipts = /* @__PURE__ */ new Map();
375
- const joinedChunks = Math.ceil(joined.length / 500);
376
- for (let c = 0; c < joinedChunks; c++) {
377
- const [events, receiptRows, completed] = results.slice(c * 3, c * 3 + 3);
378
- for (const r of events) touched.add(r.room_id);
379
- for (const r of receiptRows) {
380
- touched.add(r.room_id);
381
- receipts.set(r.room_id, [...receipts.get(r.room_id) ?? [], r]);
382
+ const memberChanges = [];
383
+ results.forEach((rows, i) => {
384
+ const kind = kinds[i];
385
+ for (const r of rows) {
386
+ if (kind === "members") memberChanges.push(r);
387
+ else touched.add(r.room_id);
388
+ if (kind === "receipts") receipts.set(r.room_id, [...receipts.get(r.room_id) ?? [], r]);
382
389
  }
383
- for (const r of completed) touched.add(r.room_id);
384
- }
385
- const memberChanges = results.slice(joinedChunks * 3).flat();
390
+ });
386
391
  const accountData = /* @__PURE__ */ new Map();
387
392
  for (const r of accountRows) {
388
393
  if (r.room_id) touched.add(r.room_id);
@@ -428,76 +433,115 @@ async function buildSync(hs, p) {
428
433
  operator: "lte",
429
434
  value: p.since.toDevice
430
435
  }]) : Promise.resolve(0);
431
- const [memberships, ignoredRows, toDeviceRows, oneTimeKeyRows, fallbackRows, accountRows = []] = await readBatch(hs.db, [
436
+ const device = `${userId}|${p.session.deviceId}`;
437
+ const [memberships, ignoredRows, toDeviceRows, oneTimeKeyRows, fallbackRows, accountRows = []] = await trackedReads(hs.db, hs.changes, [
432
438
  {
433
- model: "memberships",
434
- opts: { where: [{
435
- field: "user_id",
436
- value: userId
437
- }] }
438
- },
439
- {
440
- model: "account_data",
441
- opts: { where: [
442
- {
439
+ query: {
440
+ model: "memberships",
441
+ opts: { where: [{
443
442
  field: "user_id",
444
443
  value: userId
445
- },
446
- {
447
- field: "room_id",
448
- value: ""
449
- },
450
- {
451
- field: "type",
452
- value: M.IGNORED_USER_LIST
453
- }
454
- ] }
444
+ }] }
445
+ },
446
+ cached: {
447
+ name: "userMemberships",
448
+ key: userId
449
+ }
455
450
  },
456
451
  {
457
- model: "to_device",
458
- opts: {
459
- where: [...deviceWhere, {
460
- field: "stream",
461
- operator: "gt",
462
- value: p.since?.toDevice ?? 0
463
- }],
464
- sortBy: {
465
- field: "stream",
466
- direction: "asc"
467
- },
468
- limit: 100
452
+ query: {
453
+ model: "account_data",
454
+ opts: { where: [
455
+ {
456
+ field: "user_id",
457
+ value: userId
458
+ },
459
+ {
460
+ field: "room_id",
461
+ value: ""
462
+ },
463
+ {
464
+ field: "type",
465
+ value: M.IGNORED_USER_LIST
466
+ }
467
+ ] }
468
+ },
469
+ cached: {
470
+ name: "ignoredUsers",
471
+ key: userId
469
472
  }
470
473
  },
471
474
  {
472
- model: "one_time_keys",
473
- opts: {
474
- where: [...deviceWhere, {
475
- field: "fallback",
476
- value: false
477
- }],
478
- fields: ["algorithm"]
475
+ query: {
476
+ model: "to_device",
477
+ opts: {
478
+ where: [...deviceWhere, {
479
+ field: "stream",
480
+ operator: "gt",
481
+ value: p.since?.toDevice ?? 0
482
+ }],
483
+ sortBy: {
484
+ field: "stream",
485
+ direction: "asc"
486
+ },
487
+ limit: 100
488
+ }
489
+ },
490
+ unlessChanged: {
491
+ log: "toDevice",
492
+ key: device,
493
+ after: p.since?.toDevice ?? 0
479
494
  }
480
495
  },
481
496
  {
482
- model: "fallback_keys",
483
- opts: {
484
- where: [...deviceWhere, {
485
- field: "used",
486
- value: false
487
- }],
488
- fields: ["algorithm"]
497
+ query: {
498
+ model: "one_time_keys",
499
+ opts: {
500
+ where: [...deviceWhere, {
501
+ field: "fallback",
502
+ value: false
503
+ }],
504
+ fields: ["algorithm"]
505
+ }
506
+ },
507
+ cached: {
508
+ name: "oneTimeKeys",
509
+ key: device
510
+ }
511
+ },
512
+ {
513
+ query: {
514
+ model: "fallback_keys",
515
+ opts: {
516
+ where: [...deviceWhere, {
517
+ field: "used",
518
+ value: false
519
+ }],
520
+ fields: ["algorithm"]
521
+ }
522
+ },
523
+ cached: {
524
+ name: "fallbackKeys",
525
+ key: device
489
526
  }
490
527
  },
491
528
  ...incremental ? [{
492
- model: "account_data",
493
- opts: { where: [{
494
- field: "user_id",
495
- value: userId
496
- }, {
497
- field: "stream",
498
- operator: "gt",
499
- value: since
500
- }] }
529
+ query: {
530
+ model: "account_data",
531
+ opts: { where: [{
532
+ field: "user_id",
533
+ value: userId
534
+ }, {
535
+ field: "stream",
536
+ operator: "gt",
537
+ value: since
538
+ }] }
539
+ },
540
+ unlessChanged: {
541
+ log: "accountData",
542
+ key: userId,
543
+ after: since
544
+ }
501
545
  }] : []
502
546
  ]);
503
547
  p = {
@@ -644,17 +688,31 @@ async function buildSync(hs, p) {
644
688
  value: []
645
689
  }] }
646
690
  };
647
- let [presenceRows = [], newlySharedPresence = [], deviceRows] = await readBatch(hs.db, [
648
- presenceQuery,
649
- {
691
+ let [presenceRows = [], newlySharedPresence = [], deviceRows] = await trackedReads(hs.db, hs.changes, [
692
+ since === null ? { query: presenceQuery } : {
693
+ query: presenceQuery,
694
+ unlessChanged: {
695
+ log: "presence",
696
+ key: "*",
697
+ after: since
698
+ }
699
+ },
700
+ { query: {
650
701
  model: "presence",
651
702
  opts: { where: [{
652
703
  field: "user_id",
653
704
  operator: "in",
654
705
  value: [...newlyShared]
655
706
  }] }
656
- },
657
- ...since !== null ? [recent("device_list_changes")] : []
707
+ } },
708
+ ...since !== null ? [{
709
+ query: recent("device_list_changes"),
710
+ unlessChanged: {
711
+ log: "deviceLists",
712
+ key: "*",
713
+ after: since
714
+ }
715
+ }] : []
658
716
  ]);
659
717
  if (since !== null) {
660
718
  if (presenceRows.length > RECENT_CHANGES) presenceRows = await hs.db.findMany("presence", { where: [{
@@ -745,14 +803,12 @@ async function sync(ctx) {
745
803
  const timeout = Math.min(Math.max(Number.parseInt(ctx.query.get("timeout") ?? "0", 10) || 0, 0), hs.config.maxSyncTimeoutMs);
746
804
  const setPresence = ctx.query.get("set_presence") ?? "online";
747
805
  if (setPresence !== "offline") await touchPresence(hs, session.userId, setPresence);
748
- const deadline = Date.now() + timeout;
749
- for (;;) {
750
- const to = await hs.streams.position();
751
- const ignored = /* @__PURE__ */ new Set();
752
- const stable = ctx.query.get("use_state_after") === "true";
753
- const unstable = ctx.query.get(UNSTABLE.ORG.MATRIX.MSC4222.USE_STATE_AFTER) === "true";
754
- const useStateAfter = stable || unstable;
755
- const stateAfterKey = stable ? "state_after" : UNSTABLE.ORG.MATRIX.MSC4222.STATE_AFTER;
806
+ const ignored = /* @__PURE__ */ new Set();
807
+ const stable = ctx.query.get("use_state_after") === "true";
808
+ const unstable = ctx.query.get(UNSTABLE.ORG.MATRIX.MSC4222.USE_STATE_AFTER) === "true";
809
+ const useStateAfter = stable || unstable;
810
+ const stateAfterKey = stable ? "state_after" : UNSTABLE.ORG.MATRIX.MSC4222.STATE_AFTER;
811
+ const { value } = await longPoll(hs, session.userId, timeout, ctx.request.signal, async (to) => {
756
812
  const { body, empty } = await syncSlots(hs).run(() => buildSync(hs, {
757
813
  session,
758
814
  ignored,
@@ -763,20 +819,21 @@ async function sync(ctx) {
763
819
  useStateAfter,
764
820
  stateAfterKey
765
821
  }));
766
- const remaining = deadline - Date.now();
767
- if (!since || !empty || remaining <= 0 || fullState) return body;
768
- if (await hs.streams.position() > to) continue;
769
- await hs.notifier.wait(session.userId, remaining, ctx.request.signal);
770
- if (ctx.request.signal?.aborted) return body;
771
- }
822
+ return {
823
+ value: body,
824
+ done: !since || !empty || fullState
825
+ };
826
+ });
827
+ return value;
772
828
  }
773
829
  const LAST_ACTIVE_GRANULARITY_MS = 6e4;
774
830
  async function touchPresence(hs, userId, presence) {
775
831
  const state = presence === "unavailable" ? "unavailable" : "online";
776
- const row = await hs.db.findOne("presence", [{
832
+ const read = () => hs.db.findOne("presence", [{
777
833
  field: "user_id",
778
834
  value: userId
779
835
  }]);
836
+ const row = hs.changes ? await hs.changes.values.presenceRow.get(userId, read) : await read();
780
837
  if (row && row.presence === state) {
781
838
  if (Date.now() - row.last_active_ts >= LAST_ACTIVE_GRANULARITY_MS) await hs.db.update("presence", [{
782
839
  field: "user_id",
@@ -784,13 +841,17 @@ async function touchPresence(hs, userId, presence) {
784
841
  }], { last_active_ts: Date.now() });
785
842
  return;
786
843
  }
787
- await hs.streams.write((stream) => hs.db.upsert("presence", {
788
- user_id: userId,
789
- presence: state,
790
- status_msg: row?.status_msg ?? null,
791
- last_active_ts: Date.now(),
792
- stream
793
- }));
844
+ const stream = await hs.streams.write(async (stream) => {
845
+ await hs.db.upsert("presence", {
846
+ user_id: userId,
847
+ presence: state,
848
+ status_msg: row?.status_msg ?? null,
849
+ last_active_ts: Date.now(),
850
+ stream
851
+ });
852
+ return stream;
853
+ });
854
+ hs.notifier.notify([userId, ...await hs.rooms.usersSharingRooms(userId)], stream);
794
855
  await hs.federation.onPresence(userId, state, row?.status_msg ?? null).catch(() => {});
795
856
  }
796
857
  function registerSyncRoutes(r) {
@@ -301,6 +301,7 @@ var AdminService = class {
301
301
  content: { tags: { [M.SERVER_NOTICE]: { order: .5 } } },
302
302
  stream
303
303
  }));
304
+ this.hs.notifier.notify([userId]);
304
305
  return room_id;
305
306
  }
306
307
  };