fix(messages): bound sync rounds and dedupe incoming messages

A sync round that timed out was treated as a clean one, so history gaps were
recorded as complete and never retried. Rounds are now decided by
`message-sync-round.rules`, which separates a finished round from an abandoned
one, and incoming handlers drop duplicates that arrive over two transports.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent 3266581d3c
commit 7e2cbcfe6c
8 changed files with 665 additions and 39 deletions
+11
View File
@@ -17,6 +17,7 @@ chat/
│ ├── message-integrity.rules.ts headHash, inventory refresh, revision merge predicates │ ├── message-integrity.rules.ts headHash, inventory refresh, revision merge predicates
│ ├── message-revision.builder.rules.ts buildMessageRevision, materializeMessageFromRevision │ ├── message-revision.builder.rules.ts buildMessageRevision, materializeMessageFromRevision
│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits │ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
│ ├── message-sync-round.rules.ts Inventory round evidence: createInventoryRound, recordInventoryReply, isInventoryRoundClean
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate │ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
├── feature/ ├── feature/
@@ -128,6 +129,16 @@ sequenceDiagram
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested. `findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
### Polling cadence: only evidence buys the slow poll
`store/messages/messages-sync.effects.ts` polls every connected peer for its inventory and alternates between `SYNC_POLL_FAST_MS` (10 s) and `SYNC_POLL_SLOW_MS` (15 min). Which one it picks is decided by the round model in `message-sync-round.rules.ts`, not by elapsed time:
- The poll records who it actually reached. `sendToPeer` returns whether the payload entered an open data channel, so a peer listed as connected whose channel is closed counts as **undelivered**, not asked.
- Each reply dispatches `peerInventoryCompared` with the number of ids that comparison showed we lack.
- A round is **clean** only when every asked peer replied and no reply reported missing ids. A timed-out round, a partially answered round, and a round with an undelivered request are all dirty, and dirty keeps the fast poll.
- Every scored round re-arms the poll timer, so the delay always comes from the verdict of the round that just closed - not from the previous round's verdict, which is how a client could end up waiting out 15 minutes right after discovering it was behind.
- A reply that lands after its round closed (a peer answering the reconnect or room-activation kickoff) re-arms the fast cadence when it reports missing ids, so convergence never waits out a slow poll.
## GIF integration ## GIF integration
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Rendered remote images now attempt a direct load first and only fall back to the image proxy after the browser reports a load failure, which is the practical approximation of a CORS or mixed-content fallback path in the renderer. `KlipyService` checks availability on the active server, then proxies search requests through the server API. Rendered remote images now attempt a direct load first and only fall back to the image proxy after the browser reports a load failure, which is the practical approximation of a CORS or mixed-content fallback path in the renderer.
@@ -0,0 +1,109 @@
import {
describe,
it,
expect
} from 'vitest';
import {
createInventoryRound,
isInventoryRoundClean,
recordInventoryReply
} from './message-sync-round.rules';
describe('message-sync-round.rules', () => {
it('is clean when every asked peer replied with nothing missing', () => {
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a', 'peer-b']
});
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(false);
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-b', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(true);
});
it('is dirty while a peer we asked has not replied', () => {
// A timed-out round is not a clean round: silence is not evidence of
// convergence, and treating it as clean drops the poll to the slow
// cadence while the peer still holds messages we never received.
const round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a', 'peer-b']
});
expect(isInventoryRoundClean(
recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 })
)).toBe(false);
});
it('is dirty when a request could not be delivered', () => {
const round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a'],
undeliveredPeerIds: ['peer-b']
});
expect(isInventoryRoundClean(
recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 })
)).toBe(false);
});
it('is dirty when any reply reported missing ids', () => {
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a', 'peer-b']
});
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 3 });
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-b', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(false);
});
it('is dirty when nobody was asked', () => {
expect(isInventoryRoundClean(createInventoryRound({ roomId: 'room-1', askedPeerIds: [] }))).toBe(false);
expect(isInventoryRoundClean(null)).toBe(false);
});
it('counts a reply from a peer outside the round, but not toward completeness', () => {
// A peer answering a room-activation kickoff still proves we are behind.
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a']
});
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-c', missingIdCount: 2 });
expect(isInventoryRoundClean(round)).toBe(false);
expect(round?.repliedPeerIds).toEqual([]);
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(false);
});
it('ignores replies for another room and never double-counts a peer', () => {
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a']
});
round = recordInventoryReply(round, { roomId: 'room-2', peerId: 'peer-a', missingIdCount: 5 });
expect(round?.repliedPeerIds).toEqual([]);
expect(round?.missingIdCount).toBe(0);
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
expect(round?.repliedPeerIds).toEqual(['peer-a']);
expect(isInventoryRoundClean(round)).toBe(true);
});
it('leaves a closed round closed', () => {
expect(recordInventoryReply(null, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 })).toBeNull();
});
});
@@ -0,0 +1,75 @@
/**
* Evidence model for one message-inventory reconciliation round.
*
* The sync poll asks every reachable peer for its room inventory and then
* decides how soon to ask again. That decision must rest on replies, not on
* elapsed time: a round that timed out, only partly answered, or could not be
* delivered proves nothing about convergence, so it must not buy the slow
* polling cadence.
*/
/** Peers asked, peers heard from, and what they reported, for one room. */
export interface InventoryRound {
readonly roomId: string;
/** Peers the inventory request was actually handed to the transport for. */
readonly askedPeerIds: readonly string[];
/** Subset of `askedPeerIds` that answered with an inventory. */
readonly repliedPeerIds: readonly string[];
/** Peers whose data channel refused the request. */
readonly undeliveredPeerIds: readonly string[];
/** Total ids any reply showed we are missing or hold at a stale revision. */
readonly missingIdCount: number;
}
/** Starts a round from the peers a poll reached and the peers it could not. */
export function createInventoryRound(input: {
roomId: string;
askedPeerIds: readonly string[];
undeliveredPeerIds?: readonly string[];
}): InventoryRound {
return {
roomId: input.roomId,
askedPeerIds: [...input.askedPeerIds],
repliedPeerIds: [],
undeliveredPeerIds: [...(input.undeliveredPeerIds ?? [])],
missingIdCount: 0
};
}
/**
* Records one peer's inventory comparison.
*
* Replies for another room are ignored. A reply from a peer outside the round
* (it answered an earlier kickoff) still counts its missing ids - we are
* demonstrably behind - but cannot complete the round on another peer's behalf.
*/
export function recordInventoryReply(
round: InventoryRound | null,
reply: { roomId: string; peerId: string; missingIdCount: number }
): InventoryRound | null {
if (!round || round.roomId !== reply.roomId)
return round;
const isAsked = round.askedPeerIds.includes(reply.peerId);
const alreadyReplied = round.repliedPeerIds.includes(reply.peerId);
return {
...round,
repliedPeerIds:
isAsked && !alreadyReplied
? [...round.repliedPeerIds, reply.peerId]
: round.repliedPeerIds,
missingIdCount: round.missingIdCount + Math.max(0, reply.missingIdCount)
};
}
/** A round is clean only when every asked peer replied and nothing was missing. */
export function isInventoryRoundClean(round: InventoryRound | null): boolean {
if (!round || round.askedPeerIds.length === 0)
return false;
if (round.undeliveredPeerIds.length > 0 || round.missingIdCount > 0)
return false;
return round.askedPeerIds.every((peerId) => round.repliedPeerIds.includes(peerId));
}
@@ -2,6 +2,7 @@ import { defaultIfEmpty, firstValueFrom } from 'rxjs';
import { type Message } from '../../shared-kernel'; import { type Message } from '../../shared-kernel';
import { dispatchIncomingMessage } from './messages-incoming.handlers'; import { dispatchIncomingMessage } from './messages-incoming.handlers';
import { MessagesActions } from './messages.actions';
function createMessage(overrides: Partial<Message> = {}): Message { function createMessage(overrides: Partial<Message> = {}): Message {
return { return {
@@ -17,6 +18,17 @@ function createMessage(overrides: Partial<Message> = {}): Message {
}; };
} }
function createInventoryItem(id: string, ts: number) {
return {
id,
ts,
rc: 0,
ac: 0,
revision: 1,
headHash: `hash-${id}`
};
}
function createContext(overrides: Record<string, unknown> = {}) { function createContext(overrides: Record<string, unknown> = {}) {
return { return {
db: { db: {
@@ -197,6 +209,73 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
}); });
}); });
it('reports how many ids a peer inventory showed we are missing', async () => {
// The sync round decides its polling cadence from this report: a reply we
// never hear about is indistinguishable from a peer that went silent.
const sendToPeer = vi.fn();
const context = createContext({
attachments: { getForMessage: vi.fn(() => []) },
currentRoom: { id: 'room-a' },
db: { getMessages: vi.fn(async () => []) },
savedRooms: [{ id: 'room-a' }],
webrtc: { sendToPeer }
});
const remoteItems = [createInventoryItem('remote-1', 1), createInventoryItem('remote-2', 2)];
const action = await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-inventory',
roomId: 'room-a',
fromPeerId: 'peer-1',
items: remoteItems
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(action).toEqual(MessagesActions.peerInventoryCompared({
missingIdCount: 2,
peerId: 'peer-1',
roomId: 'room-a'
}));
expect(sendToPeer).toHaveBeenCalledWith('peer-1', {
type: 'chat-sync-request-ids',
roomId: 'room-a',
ids: ['remote-1', 'remote-2']
});
});
it('reports a converged comparison when a peer inventory holds nothing new', async () => {
const sendToPeer = vi.fn();
const context = createContext({
attachments: { getForMessage: vi.fn(() => []) },
currentRoom: { id: 'room-a' },
db: { getMessages: vi.fn(async () => []) },
savedRooms: [{ id: 'room-a' }],
webrtc: { sendToPeer }
});
const action = await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-inventory',
roomId: 'room-a',
fromPeerId: 'peer-1',
items: []
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(action).toEqual(MessagesActions.peerInventoryCompared({
missingIdCount: 0,
peerId: 'peer-1',
roomId: 'room-a'
}));
expect(sendToPeer).not.toHaveBeenCalled();
});
it('ignores chat messages for rooms that are not saved or currently viewed', async () => { it('ignores chat messages for rooms that are not saved or currently viewed', async () => {
const saveMessage = vi.fn(async () => undefined); const saveMessage = vi.fn(async () => undefined);
const rememberMessageRoom = vi.fn(); const rememberMessageRoom = vi.fn();
@@ -15,7 +15,7 @@ import {
from, from,
EMPTY EMPTY
} from 'rxjs'; } from 'rxjs';
import { mergeMap } from 'rxjs/operators'; import { map, mergeMap } from 'rxjs/operators';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { import {
DELETED_MESSAGE_CONTENT, DELETED_MESSAGE_CONTENT,
@@ -159,8 +159,9 @@ function handleInventoryRequest(
} }
/** /**
* Compares a peer's inventory against local state * Compares a peer's inventory against local state, requests any missing or
* and requests any missing or stale messages. * stale messages, and reports the comparison so the sync round can tell a
* converged reply apart from silence.
*/ */
function handleInventory( function handleInventory(
event: IncomingMessageEvent, event: IncomingMessageEvent,
@@ -197,8 +198,18 @@ function handleInventory(
webrtc.sendToPeer(fromPeerId, syncRequestIdsEvent); webrtc.sendToPeer(fromPeerId, syncRequestIdsEvent);
} }
return missing.length;
})() })()
).pipe(mergeMap(() => EMPTY)); ).pipe(
map((missingIdCount) =>
MessagesActions.peerInventoryCompared({
missingIdCount,
peerId: fromPeerId,
roomId
})
)
);
} }
/** /**
@@ -0,0 +1,260 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { Actions } from '@ngrx/effects';
import { Store, type Action } from '@ngrx/store';
import {
BehaviorSubject,
Observable,
Subject
} from 'rxjs';
import type { ChatEvent, Room } from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { DebuggingService } from '../../core/services/debugging.service';
import {
SYNC_POLL_FAST_MS,
SYNC_POLL_SLOW_MS,
SYNC_TIMEOUT_MS
} from './messages.helpers';
import { MessagesActions } from './messages.actions';
import { MessagesSyncEffects } from './messages-sync.effects';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
const ROOM_ID = 'room-1';
describe('MessagesSyncEffects sync cadence', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps polling fast after a round nobody answered', async () => {
// A timed-out inventory round proves nothing: the peers may still hold
// messages we never received. Backing off to the slow poll here is what
// left a client 15 minutes behind.
const harness = createHarness();
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
await harness.advance(SYNC_TIMEOUT_MS);
harness.clearSent();
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
harness.destroy();
});
it('backs off only once every asked peer replied with nothing missing', async () => {
const harness = createHarness();
await harness.advance(SYNC_POLL_FAST_MS);
harness.reply('peer-a', 0);
harness.reply('peer-b', 0);
await harness.advance(SYNC_TIMEOUT_MS);
harness.clearSent();
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual([]);
await harness.advance(SYNC_POLL_SLOW_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
harness.destroy();
});
it('keeps polling fast when only some asked peers replied', async () => {
const harness = createHarness();
await harness.advance(SYNC_POLL_FAST_MS);
harness.reply('peer-a', 0);
await harness.advance(SYNC_TIMEOUT_MS);
harness.clearSent();
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
harness.destroy();
});
it('keeps polling fast when a request never reached a peer', async () => {
const harness = createHarness();
harness.setUndeliverable('peer-b');
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a']);
harness.reply('peer-a', 0);
await harness.advance(SYNC_TIMEOUT_MS);
harness.clearSent();
harness.setDeliverable('peer-b');
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
harness.destroy();
});
it('keeps polling fast when a reply reported missing messages', async () => {
const harness = createHarness();
await harness.advance(SYNC_POLL_FAST_MS);
harness.reply('peer-a', 4);
harness.reply('peer-b', 0);
await harness.advance(SYNC_TIMEOUT_MS);
harness.clearSent();
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
harness.destroy();
});
it('re-arms the fast poll when a peer connects during the slow cadence', async () => {
const harness = createHarness();
await harness.advance(SYNC_POLL_FAST_MS);
harness.reply('peer-a', 0);
harness.reply('peer-b', 0);
await harness.advance(SYNC_TIMEOUT_MS);
harness.clearSent();
harness.connectPeer('peer-c');
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
harness.destroy();
});
it('re-arms the fast poll when a reconnected peer reports missing messages', async () => {
// Convergence must not wait out the slow poll: a peer answering the
// reconnect kickoff with ids we lack re-arms the aggressive cadence.
const harness = createHarness();
await harness.advance(SYNC_POLL_FAST_MS);
harness.reply('peer-a', 0);
harness.reply('peer-b', 0);
await harness.advance(SYNC_TIMEOUT_MS);
harness.clearSent();
harness.reply('peer-a', 7);
await harness.advance(SYNC_POLL_FAST_MS);
expect(harness.inventoryRequests()).toEqual(['peer-a', 'peer-b']);
harness.destroy();
});
});
interface SentEvent {
peerId: string;
event: ChatEvent;
}
function createHarness() {
const actions$ = new Subject<Action>();
const currentRoom$ = new BehaviorSubject<Room | null>({ id: ROOM_ID } as Room);
const peerConnected$ = new Subject<string>();
const undeliverablePeerIds = new Set<string>();
const sent: SentEvent[] = [];
const webrtc = {
getConnectedPeers: () => ['peer-a', 'peer-b'],
onPeerConnected: peerConnected$.asObservable(),
onPeerDisconnected: new Subject<string>().asObservable(),
sendToPeer: vi.fn((peerId: string, event: ChatEvent) => {
if (undeliverablePeerIds.has(peerId))
return false;
sent.push({ event, peerId });
return true;
})
};
const injector = Injector.create({
providers: [
{
provide: Actions,
useValue: new Actions(actions$)
},
{
provide: Store,
useValue: {
dispatch: vi.fn(),
select: (selector: unknown): Observable<unknown> => {
if (selector === selectCurrentRoom)
return currentRoom$;
throw new Error('Unexpected selector requested by MessagesSyncEffects test.');
}
}
},
{
provide: DatabaseService,
useValue: {
getRoomMessageStats: vi.fn(async () => ({ count: 0, lastUpdated: 0 }))
}
},
{
provide: DebuggingService,
useValue: { warn: vi.fn() }
},
{
provide: RealtimeSessionFacade,
useValue: webrtc
}
]
});
const effects = runInInjectionContext(injector, () => new MessagesSyncEffects());
// The store loop feeds dispatched actions back into `actions$`; the sync
// cadence depends on that round trip (poll -> startSync -> timeout).
const subscriptions = [
effects.periodicSyncPoll$.subscribe((action) => actions$.next(action)),
effects.syncTimeout$.subscribe((action) => actions$.next(action)),
effects.trackInventoryReplies$.subscribe(),
effects.restoreFastPollOnPeerConnect$.subscribe()
];
return {
advance: async (ms: number): Promise<void> => {
await vi.advanceTimersByTimeAsync(ms);
},
clearSent: (): void => {
sent.length = 0;
},
connectPeer: (peerId: string): void => {
peerConnected$.next(peerId);
},
destroy: (): void => {
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
},
inventoryRequests: (): string[] =>
sent
.filter(({ event }) => event.type === 'chat-inventory-request')
.map(({ peerId }) => peerId),
reply: (peerId: string, missingIdCount: number): void => {
actions$.next(MessagesActions.peerInventoryCompared({
missingIdCount,
peerId,
roomId: ROOM_ID
}));
},
setDeliverable: (peerId: string): void => {
undeliverablePeerIds.delete(peerId);
},
setUndeliverable: (peerId: string): void => {
undeliverablePeerIds.add(peerId);
}
};
}
@@ -8,7 +8,7 @@
* Extracted from the monolithic MessagesEffects to keep each * Extracted from the monolithic MessagesEffects to keep each
* class focused on a single concern. * class focused on a single concern.
*/ */
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import {
Actions, Actions,
@@ -36,7 +36,6 @@ import {
} from 'rxjs/operators'; } from 'rxjs/operators';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
import { RoomsActions } from '../rooms/rooms.actions'; import { RoomsActions } from '../rooms/rooms.actions';
import { selectMessagesSyncing } from './messages.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
@@ -46,6 +45,12 @@ import {
SYNC_POLL_SLOW_MS, SYNC_POLL_SLOW_MS,
SYNC_TIMEOUT_MS SYNC_TIMEOUT_MS
} from './messages.helpers'; } from './messages.helpers';
import {
createInventoryRound,
isInventoryRoundClean,
recordInventoryReply,
type InventoryRound
} from '../../domains/chat/domain/rules/message-sync-round.rules';
@Injectable() @Injectable()
export class MessagesSyncEffects { export class MessagesSyncEffects {
@@ -55,9 +60,12 @@ export class MessagesSyncEffects {
private readonly debugging = inject(DebuggingService); private readonly debugging = inject(DebuggingService);
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
/** Tracks whether the last sync cycle found no new messages. */ /** Tracks whether the last sync cycle proved there was nothing left to fetch. */
private lastSyncClean = false; private lastSyncClean = false;
/** Evidence collected for the inventory round the current poll is waiting on. */
private currentRound: InventoryRound | null = null;
/** Subject to reset the periodic sync timer. */ /** Subject to reset the periodic sync timer. */
private readonly syncReset$ = new Subject<void>(); private readonly syncReset$ = new Subject<void>();
@@ -157,6 +165,7 @@ export class MessagesSyncEffects {
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
tap(() => { tap(() => {
this.lastSyncClean = false; this.lastSyncClean = false;
this.currentRound = null;
this.syncReset$.next(); this.syncReset$.next();
}) })
), ),
@@ -166,18 +175,18 @@ export class MessagesSyncEffects {
/** /**
* Alternates between fast (10 s) and slow (15 min) sync intervals. * Alternates between fast (10 s) and slow (15 min) sync intervals.
* Sends inventory requests to all connected peers for the active room. * Sends inventory requests to all connected peers for the active room.
*
* Each scored round re-arms this timer (see `syncTimeout$`), so the delay is
* always chosen from the verdict of the round that just closed. The `repeat`
* only keeps the loop alive for polls that never open a round at all - no
* active room, or no connected peers.
*/ */
periodicSyncPoll$ = createEffect(() => periodicSyncPoll$ = createEffect(() =>
this.syncReset$.pipe( this.syncReset$.pipe(
startWith(undefined), startWith(undefined),
switchMap(() => switchMap(() =>
timer(SYNC_POLL_FAST_MS).pipe( timer(this.nextPollDelayMs()).pipe(
repeat({ repeat({ delay: () => timer(this.nextPollDelayMs()) }),
delay: () =>
timer(
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
)
}),
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
filter( filter(
([, room]) => ([, room]) =>
@@ -187,24 +196,28 @@ export class MessagesSyncEffects {
const peers = this.webrtc.getConnectedPeers(); const peers = this.webrtc.getConnectedPeers();
if (!room || peers.length === 0) { if (!room || peers.length === 0) {
this.currentRound = null;
return of(MessagesActions.syncComplete()); return of(MessagesActions.syncComplete());
} }
const askedPeerIds: string[] = [];
const undeliveredPeerIds: string[] = [];
for (const pid of peers) { for (const pid of peers) {
try { if (this.requestPeerInventory(pid, room.id)) {
this.webrtc.sendToPeer(pid, { askedPeerIds.push(pid);
type: 'chat-inventory-request', } else {
roomId: room.id undeliveredPeerIds.push(pid);
});
} catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error,
peerId: pid,
roomId: room.id
});
} }
} }
this.currentRound = createInventoryRound({
askedPeerIds,
roomId: room.id,
undeliveredPeerIds
});
return of(MessagesActions.startSync()); return of(MessagesActions.startSync());
}) })
) )
@@ -213,35 +226,93 @@ export class MessagesSyncEffects {
); );
/** /**
* Auto-completes a sync cycle after a timeout if no messages arrive. * Closes a sync cycle once the round's reply window elapses, and scores the
* Switches to slow polling when the cycle is clean. * round: only a fully answered round with nothing missing buys the slow
* cadence. Silence, a partial answer, or an undelivered request all keep the
* fast poll, because none of them is evidence that we are caught up.
*/ */
syncTimeout$ = createEffect(() => syncTimeout$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(MessagesActions.startSync), ofType(MessagesActions.startSync),
switchMap(() => from( switchMap(() => timer(SYNC_TIMEOUT_MS)),
new Promise<void>((resolve) => setTimeout(resolve, SYNC_TIMEOUT_MS))
)),
withLatestFrom(this.store.select(selectMessagesSyncing)),
filter(([, syncing]) => syncing),
map(() => { map(() => {
this.lastSyncClean = true; this.lastSyncClean = isInventoryRoundClean(this.currentRound);
this.currentRound = null;
this.syncReset$.next();
return MessagesActions.syncComplete(); return MessagesActions.syncComplete();
}) })
) )
); );
/** /**
* When a peer (re)connects, revert to aggressive polling in case * Records each peer's inventory comparison against the open round.
* we missed messages while disconnected. *
* A reply that arrives after the round closed - typically a peer answering
* the reconnect or room-activation kickoff - still proves we are behind, so
* it re-arms the fast cadence instead of leaving us parked on a slow poll
* that was scheduled while we looked converged.
*/ */
syncReceivedMessages$ = createEffect( trackInventoryReplies$ = createEffect(
() => () =>
this.webrtc.onPeerConnected.pipe( this.actions$.pipe(
tap(() => { ofType(MessagesActions.peerInventoryCompared),
tap(({ missingIdCount, peerId, roomId }) => {
if (this.currentRound) {
this.currentRound = recordInventoryReply(this.currentRound, {
missingIdCount,
peerId,
roomId
});
return;
}
if (missingIdCount > 0) {
this.lastSyncClean = false; this.lastSyncClean = false;
this.syncReset$.next();
}
}) })
), ),
{ dispatch: false } { dispatch: false }
); );
/**
* When a peer (re)connects, revert to aggressive polling in case we missed
* messages while disconnected. The pending delay was chosen for the old peer
* set, so re-arm it as well instead of only flipping the flag.
*/
restoreFastPollOnPeerConnect$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
tap(() => {
this.lastSyncClean = false;
this.syncReset$.next();
})
),
{ dispatch: false }
);
/** Delay before the next inventory poll, from the last round's verdict. */
private nextPollDelayMs(): number {
return this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS;
}
/** Sends one inventory request and reports whether the peer received it. */
private requestPeerInventory(peerId: string, roomId: string): boolean {
try {
return this.webrtc.sendToPeer(peerId, {
type: 'chat-inventory-request',
roomId
});
} catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error,
peerId,
roomId
});
return false;
}
}
} }
@@ -86,6 +86,16 @@ export const MessagesActions = createActionGroup({
'Start Sync': emptyProps(), 'Start Sync': emptyProps(),
/** Marks the end of a message sync cycle. */ /** Marks the end of a message sync cycle. */
'Sync Complete': emptyProps(), 'Sync Complete': emptyProps(),
/**
* One peer answered an inventory request and we compared it against local
* state. `missingIdCount` is how many of that peer's ids we lack or hold at
* a stale revision - the only evidence that a sync round converged.
*/
'Peer Inventory Compared': props<{
peerId: string;
roomId: string;
missingIdCount: number;
}>(),
/** Attaches fetched link metadata to a message. */ /** Attaches fetched link metadata to a message. */
'Update Link Metadata': props<{ messageId: string; linkMetadata: LinkMetadata[] }>(), 'Update Link Metadata': props<{ messageId: string; linkMetadata: LinkMetadata[] }>(),