fix: relay mark_unread to peers, fix docs and timestamp consistency

- Add queueReadStateRelay call in handleMarkUnread (skip '0' sentinel)
- Fix double Date.now() in queueReadStateRelay (use single const)
- Fix federation.md: read state relay uses outbox (not fire-and-forget),
  correct payload schema to match implementation
This commit is contained in:
Jannis Braun
2026-04-07 20:08:31 +02:00
parent 626a8ded50
commit 1ada46baff
3 changed files with 31 additions and 18 deletions
+22 -16
View File
@@ -633,37 +633,43 @@ Uses the same backoff schedule as outbox delivery. Max attempts: 10 (`MAX_FILE_A
### `read_state_update` Event
When a user marks a DM channel as read (via `channel_ack` WS event), the read state is relayed to all peer instances so cross-instance sessions stay in sync.
When a user marks a DM channel as read (`channel_ack`) or marks it unread (`mark_unread`), the read state is relayed to all peer instances so cross-instance sessions stay in sync.
**Outbound (`events.ts:handleChannelAck`):**
**Outbound (`events.ts:handleChannelAck` / `handleMarkUnread`):**
- Fires after writing `read_states` locally
- Only triggers for DM channels (channel ID found in `dm_members`)
- Calls `sendReadStateRelay(dmChannelId, userId, messageId)` in `federationOutbox.ts`
- Fire-and-forget — not queued via outbox, sent directly to all active peers (same pattern as typing relay)
- Only triggers for DM channels with a `federatedId` (cross-instance DMs)
- Calls `queueReadStateRelay(channelId, messageId, userId)` in `federationOutbox.ts`
- Queued via the standard outbox pipeline — durable, retried by the background worker
- Entity key `read_state:{federatedId}:{userId}` enables coalescing (rapid acks collapse to latest)
- `mark_unread` with the `'0'` sentinel (delete read state entirely) is NOT relayed — it cannot be mapped to a message
**Event payload:**
```typescript
{
eventType: 'read_state_update',
contextType: 'dm',
federatedId: string, // DM channel's federatedId (cross-instance channel lookup)
dmChannelId: string,
messageId: string, // unique event ID: 'read_state:{userId}:{timestamp}'
federatedId: string, // DM channel's federatedId (cross-instance channel lookup)
encryptionVersion: 0,
timestamp: number, // LWW tiebreaker
readState: {
user: { homeUserId: string, homeInstance: string },
messageRef: { messageId: string, sourceInstance: string | null }
user: { homeUserId: string; homeInstance: string },
messageRef: { sourceInstance: string; sourceMessageId: string }
}
}
```
`messageRef.sourceInstance` is non-null when the acknowledged message was relayed from another instance (i.e., it has a `source_instance` in `dm_messages`). The receiving instance uses this to locate the correct local message row.
`messageRef` identifies the acked message in federation coordinates. If the message originated on this instance, `sourceInstance` is our own origin and `sourceMessageId` is the local message ID. If the message was relayed here, `sourceInstance` and `sourceMessageId` come from the `dm_messages` row's `source_instance`/`source_message_id` columns.
**Inbound (`processReadStateUpdateEvent`):**
1. Resolve channel by `federatedId` — reject if not found
2. Resolve user via `resolveLocalUser`skip if not found (no-op, fire-and-forget)
3. Resolve the message by `messageRef` (local ID if `sourceInstance` is null, otherwise by `source_instance + source_message_id`)
4. Upsert `read_states` row for the resolved local user and message
5. Broadcast `channel_ack` to all of the user's local WebSocket connections (multi-tab/multi-device sync)
**Not persisted in outbox or mutation log**read state relay is ephemeral; missed deliveries are not retried.
2. Resolve user via `resolveLocalUser`reject if not found
3. Translate `messageRef` to local message ID:
- If `sourceInstance` matches our origin: `sourceMessageId` IS our local ID
- Otherwise: look up `dm_messages` by `source_instance + source_message_id`
4. If no local message found (relay hasn't arrived yet): silently accept (no-op)
5. Upsert `read_states` using timestamp-only LWW (`event.timestamp > existing.updatedAt`)
6. Echo `channel_ack` to the user's local WebSocket connections (multi-tab sync)
---
@@ -519,13 +519,14 @@ export function queueReadStateRelay(
messageRef = { sourceInstance: ourOrigin, sourceMessageId: messageId };
}
const now = Date.now();
const payload: FederationRelayEvent = {
eventType: 'read_state_update',
dmChannelId: channelId,
messageId: `read_state:${userId}:${Date.now()}`,
messageId: `read_state:${userId}:${now}`,
federatedId: channel.federatedId,
encryptionVersion: 0,
timestamp: Date.now(),
timestamp: now,
readState: {
user: { homeUserId, homeInstance },
messageRef,
+6
View File
@@ -1384,6 +1384,12 @@ function handleMarkUnread(event: Record<string, unknown>, userId: string, isFede
channelId,
messageId,
});
// Relay mark-unread to federated peers for cross-instance sync
// (skip the '0' sentinel — it deletes the read state and can't be mapped to a message)
if (messageId !== '0') {
queueReadStateRelay(channelId, messageId, userId);
}
}
// ─── DM Call Handlers (Unified Room API) ───────────────────────────────────