Commit 34fbbc38 authored by Tomáš Hübelbauer's avatar Tomáš Hübelbauer

Document read receipt approach for further pondering

parent d7678145
# Read Receipts
We offer splitting messages into chunks in the library itself, so that the library users do not have to track this and can make use of a simple API.
The way this is achieved is we accept messages through `broadcast` or `cast` and cyclically display only chunks of those messages with some metadata.
The messages and their chunks are iterated sequentially, skipping the chunks we already know were received by the counterparty.
In order to know when it is safe so stop displaying a certain chunk, we need some sort of a confirmation of reception from the counterparty.
Once we know all chunks were received, we can stop displaying the message altogether and let the sender know the recipient has received it.
That's what read receipt portion of the message header is for.
We a peer A displays a broadcast and peer B notices it, it extends whatever it is displaying currently (broadcast or cast) by read receipt metadata.
From that point on, peer B tells the world it has seen a chunk (of a particular number) or a message (of a particular) number from a particual peer.
This information is ignored by peers it is not directed to, but is processed by the peer it is directed to, the peer A.
Once the peer A gets a wind of this message chunk read receipt, it marks that chunks as transferred (invoking the send progress event handler)
and no longer shows that chunk of the message. That is, unless it is a broadcast message, in which case it continues showing it for other peers.
But if it is a cast message, it skips that chunk reducing the total amount of chunks it needs to go through in order to transfer the whole message.
Peer A also needs to let the peer B know that it has seen its read receipt and it no longer needs to keep showing it.
If we didn't faciliate this, peer B would have to display a read receipt for any received chunk and the amount of metadata to transfer would soon
grow so large, exchanging it would go so slow, backups would be caused on the sender side (sender not knowing it can stop displaying some chunks in time).
In order to resolve that potential problem, peer A starts displaying a read receipt read receipt for the peer B, so that peer B can notice that and stop.
Until now, the peer A was displaying a broadcast with no metadata, so from now on, its same broadcast (its various chunks being cycled through), come with
a metadata header in which read receipts are being cycled and at this time, the read receipt for B's read receipt is the sole read receipt being shown.
B is still displaying chunks of its own broadcast, but also the read receipt for A's chunk it saw, and once processing the next frame from A,
one with the read receipt read receipt bundled in, it knows that not only did A see one of its chunk (with its read receipt), but also its read receipt.
It marks the seen chunk as seen (unless it is a broadcast chunk) and removes the read receipt it was showing.
From now on B shows a new read receipt for the read receipt from A.
- [ ] Figure out how to prevent broadcast message chunks from generating read receipts, we probably need to keep a backlog for those
- [ ] Figure out if this has flaws or can be streamlined, because that's a lot of data
- [ ] Figure out if limiting to one message at a time would have benefits (no parallel WebRTC offer and candidates, but ultimately faster?)
## Alternatives
### No read receipts
We could either send stuff slowly enough to be reasonably sure the recipient will have time to parse it,
but there are no guaranteed the devices won't move and lose sight of each other's QR codes.
We need to support that, otherwise the fragility of the system would make it useless.
### No distinction between messages and chunks
We could reduce the two numbers (`messageNumber` and `messageChunkNumber`) into one to save space when sending,
but getting rid of this distinction would mean that the recipient would have no information about how to stitch messages back together.
This could work if we limited ourselves to one message at a time, which is a fair suggestion, but not something we want as this time.
### Cycling between message numbers with their associated highest chunk number so far
If we only sent the highest chunk number received so far instead of cycling through all of them,
(this being done in an effort to signal to the other peer they only need to send chunks higher than the one received,)
we would run at risk or not seeing some low chunks, because there is no guarantee we will start seeing the chunks from the first one
as they cycle on the screen we observe.
### Cycling between message numbers with their associated lowest chunk number so far
If we only send the lowest chunk number received so far instead of cycling through all of them,
(this being done in an effort to signal to the other peer they don't need to send chunks lower than the one received,)
we would run at risk of causing extreme backups on the sending end, where by missing one or a few of the first chunks,
but then seeing a lot of higher chunks of a long message, we cause the sender to always be sending the high chunks,
even though they are not needed anymore, all until it cycles through all high chunks and gets back to the lowest chunk
we haven't received yet. This would be less of a problem with short messages, but is not a good general solution.
### Cycling between message numbers with their associated received chunk numbers so far
That's what we're doing now.
This creates way more read receipts that the alternatives where there is only one associated chunk number for each message number,
but solves all the shortcomings of those.
We don't have hard numbers on this yet, but we expect to see this redundance pay off by not causing backups in any situation.
### No cycling, concatenating read receipts in the message header
Avoiding the need to cycle through the metadata by bundling them all into the message header with the data is an insufficient solution.
With multiple peers or even just two peers with multiple messages at one time, the amount of metadata would eclipse the amount of data,
causing the QR code to grow very large (and thus complicate scanning) while providing very low throughput, to no throughout,
if the code were just to grow too large because of all the metadata.
This would be the fastest if we only ever sent one message to one peer at any one time, but doesn't scale beyond that.
### Alternating between pure data messages and read receipt metadata messages
Would this be any good? Who knows. Is the difference against the selected solution only in layout and not in speed?
...@@ -39,16 +39,20 @@ export default class Channel extends EventTarget { ...@@ -39,16 +39,20 @@ export default class Channel extends EventTarget {
private name: string; private name: string;
private sentMessages: SentMessage[] = []; private messages: SentMessage[] = [];
private sentMessageNumber = 0; private messageNumber = 0;
private sentMessageIndex = 0; private messageIndex = 0;
private sentMessageChunkIndex = 0; private messageChunkIndex = 0;
private peers: Peer[] = []; private peers: Peer[] = [];
private readReceipts: ReadReceipt[] = []; private messageReadReceipts: MessageReadReceipt[] = [];
private readReceiptNumber = 0; private messageReadReceiptNumber = 0;
private readReceiptIndex = 0; private messageReadReceiptIndex = 0;
private readReceiptReadReceipts: ReadReceiptReadReceipt[] = [];
private readReceiptReadReceiptNumber = 0;
private readReceiptReadReceiptIndex = 0;
private viewfinderVideo: HTMLVideoElement; private viewfinderVideo: HTMLVideoElement;
private snapshotCanvas: HTMLCanvasElement; private snapshotCanvas: HTMLCanvasElement;
...@@ -173,9 +177,10 @@ export default class Channel extends EventTarget { ...@@ -173,9 +177,10 @@ export default class Channel extends EventTarget {
return; return;
} }
this.readReceiptNumber++; // TODO: Create read receipt for message and read receipt only if they don't already exist
this.readReceipts.push({ this.messageReadReceiptNumber++;
number: this.readReceiptNumber, this.messageReadReceipts.push({
number: this.messageReadReceiptNumber,
recipientName: message.senderName, recipientName: message.senderName,
recipientMessageNumber: message.senderMessageNumber, recipientMessageNumber: message.senderMessageNumber,
recipientMessageChunkNumber: message.senderMessageChunkNumber, recipientMessageChunkNumber: message.senderMessageChunkNumber,
...@@ -191,11 +196,11 @@ export default class Channel extends EventTarget { ...@@ -191,11 +196,11 @@ export default class Channel extends EventTarget {
if (message.readReceipt !== null) { if (message.readReceipt !== null) {
const { recipientMessageNumber, recipientMessageChunkNumber, recipientReadReceiptNumber } = message.readReceipt; const { recipientMessageNumber, recipientMessageChunkNumber, recipientReadReceiptNumber } = message.readReceipt;
const index = this.readReceipts.findIndex(rr => rr.number === recipientReadReceiptNumber); const index = this.messageReadReceipts.findIndex(rr => rr.number === recipientReadReceiptNumber);
// Ignore not-found read receipts, because we will get a few scans with the read receipt until the counterparty sees ours where we tell it we saw it. // Ignore not-found read receipts, because we will get a few scans with the read receipt until the counterparty sees ours where we tell it we saw it.
if (index !== -1) { if (index !== -1) {
this.readReceipts.splice(index, 1); this.messageReadReceipts.splice(index, 1);
} }
// TODO: Emit `sentProgress`, `receivedProgress` and `message` events on Peer. // TODO: Emit `sentProgress`, `receivedProgress` and `message` events on Peer.
...@@ -218,12 +223,12 @@ export default class Channel extends EventTarget { ...@@ -218,12 +223,12 @@ export default class Channel extends EventTarget {
* @param message The message to remove. * @param message The message to remove.
*/ */
public unbroadcast(message: string) { public unbroadcast(message: string) {
const index = this.sentMessages.findIndex(m => m.recipientName === null /* Broadcast */ && m.message === message); const index = this.messages.findIndex(m => m.recipientName === null /* Broadcast */ && m.message === message);
if (index === -1) { if (index === -1) {
throw new Error(`There is no broadcast for the message '${message}'.`); throw new Error(`There is no broadcast for the message '${message}'.`);
} }
this.sentMessages.splice(index, 1); this.messages.splice(index, 1);
} }
/** /**
...@@ -232,8 +237,8 @@ export default class Channel extends EventTarget { ...@@ -232,8 +237,8 @@ export default class Channel extends EventTarget {
* @param message The message to cast. * @param message The message to cast.
*/ */
private cast(recipientName: string | null /* Broadcast */, message: string) { private cast(recipientName: string | null /* Broadcast */, message: string) {
this.sentMessageNumber++; this.messageNumber++;
this.sentMessages.push({ message, messageNumber: this.sentMessageNumber, recipientName }); this.messages.push({ message, messageNumber: this.messageNumber, recipientName });
// Start cycling unless we already are (then we will get to the new message by it). // Start cycling unless we already are (then we will get to the new message by it).
if (this.cyclingHandle === undefined) { if (this.cyclingHandle === undefined) {
...@@ -245,37 +250,37 @@ export default class Channel extends EventTarget { ...@@ -245,37 +250,37 @@ export default class Channel extends EventTarget {
* Displays the current message and read receipt and schedules advancing the message and read receipt to the next, or stops if there is nothing to advance to. * Displays the current message and read receipt and schedules advancing the message and read receipt to the next, or stops if there is nothing to advance to.
*/ */
private cycle() { private cycle() {
const message = this.sentMessages[this.sentMessageIndex]; const message = this.messages[this.messageIndex];
const chunkIndex = this.sentMessageChunkIndex * Channel.MESSAGE_PAYLOAD_SIZE; const chunkIndex = this.messageChunkIndex * Channel.MESSAGE_PAYLOAD_SIZE;
const chunk = message.message.slice(chunkIndex, chunkIndex + Channel.MESSAGE_PAYLOAD_SIZE); const chunk = message.message.slice(chunkIndex, chunkIndex + Channel.MESSAGE_PAYLOAD_SIZE);
const chunkCount = Math.ceil(message.message.length / Channel.MESSAGE_PAYLOAD_SIZE); const chunkCount = Math.ceil(message.message.length / Channel.MESSAGE_PAYLOAD_SIZE);
const readReceipt = this.readReceipts.length > 0 ? this.readReceipts[this.readReceiptIndex] : null; const readReceipt = this.messageReadReceipts.length > 0 ? this.messageReadReceipts[this.messageReadReceiptIndex] : null;
this.display(0, 'H', new Message(this.name, message.messageNumber, this.sentMessageChunkIndex + 1, chunkCount, message.recipientName, readReceipt, chunk)); this.display(0, 'H', new Message(this.name, message.messageNumber, this.messageChunkIndex + 1, chunkCount, message.recipientName, readReceipt, chunk));
// Move to next chunk (if any), next message (if any) or stop cycling. // Move to next chunk (if any), next message (if any) or stop cycling.
if (this.sentMessageChunkIndex === chunkCount - 1) { if (this.messageChunkIndex === chunkCount - 1) {
this.sentMessageChunkIndex = 0; this.messageChunkIndex = 0;
// Move to next message (if any) or stop cycling. // Move to next message (if any) or stop cycling.
if (this.sentMessageIndex === this.sentMessages.length - 1) { if (this.messageIndex === this.messages.length - 1) {
this.sentMessageIndex = 0; this.messageIndex = 0;
// Stop cycling and keep displaying the only chunk unless we have read receipts to keep cycling through on this one message. // Stop cycling and keep displaying the only chunk unless we have read receipts to keep cycling through on this one message.
if (this.readReceipts.length === 0 && (this.sentMessages.length === 0 || (this.sentMessages.length === 1 && this.sentMessages[0].message.length < Channel.MESSAGE_PAYLOAD_SIZE))) { if (this.messageReadReceipts.length === 0 && (this.messages.length === 0 || (this.messages.length === 1 && this.messages[0].message.length < Channel.MESSAGE_PAYLOAD_SIZE))) {
this.cyclingHandle = undefined; this.cyclingHandle = undefined;
return; return;
} }
} else { } else {
this.sentMessageIndex++; this.messageIndex++;
} }
} else { } else {
this.sentMessageChunkIndex++; this.messageChunkIndex++;
} }
// Move onto the next read receipt to show or wrap around to the first again. // Move onto the next read receipt to show or wrap around to the first again.
if (this.readReceipts.length > 1 /* Sole read receipt doesn't need advancing. */) { if (this.messageReadReceipts.length > 1 /* Sole read receipt doesn't need advancing. */) {
if (this.readReceiptIndex === this.readReceipts.length - 1) { if (this.messageReadReceiptIndex === this.messageReadReceipts.length - 1) {
this.readReceiptIndex = 0; this.messageReadReceiptIndex = 0;
} else { } else {
this.readReceiptIndex++; this.messageReadReceiptIndex++;
} }
} }
......
# Document the read receipt approach
Sailient points:
- We do bundle all read receipts into one QR code because they would never fit
- We support multiple peers, not just 1:1s
- We rely on fast rotation to showcase all read receipts in a reasonable time
- We acknowledge that a peer will end up showing one chunk many times until it sees the receipt, but there is no alternative
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment