Skip to content
academia.sh

Lesson 02 / 14

Cloud Messaging

The platform's cloud messaging service carries a message from the server to the device by device token; the collapse key and the time to live decide what becomes of it. The lesson measures which of the messages piled up on an offline device arrives and the delivery of a message whose time to live runs out.

Published Updated

In this lesson

  • Separates the roles of the device token, data payload, notification payload, collapse key and time to live in cloud messaging
  • Measures that, of the messages piled up on an offline device under the same collapse key, only the last one arrives
  • Shows that a message whose time to live runs out before the device comes online is lost or superseded
  • Shows that messages carrying no collapse key each arrive once after piling up
Contents

In the previous lesson the event was born inside the app as a notification. Most of the time the event comes from a server: an order status changes, a new message arrives, a payment is confirmed. A service stands between the server and the device, and the message is entrusted to it. If the device is off or out of network range, the service holds the message. In this lesson we ask the same question again: how many copies of this sent event reached the receiver?

// Arrival.kt — classifies how many copies of a sent event reached the receiver
class Signal(val key: String, val seq: Int)

class Arrival(val label: String, val sent: Signal, received: List<Signal>) {
    val copies: Int = received.count { it.seq == sent.seq }
    val verdict: String = when {
        copies > 1 -> "duplicated"
        copies == 1 -> "once"
        received.any { it.key == sent.key && it.seq > sent.seq } -> "superseded"
        else -> "lost"
    }

    fun line() {
        println("$label: key=${sent.key} | copies=$copies -> $verdict")
    }
}

fun countArrivals(arrivals: List<Arrival>) {
    for (name in listOf("once", "duplicated", "superseded", "lost")) {
        println("$name: ${arrivals.count { it.verdict == name }}")
    }
}

The classification is the same as in the first lesson. The rule counts the copies in the receiver’s list that carry the same sequence number, and if there is no copy, it checks whether a later event with the same key arrived. It does not see the content or the cause.

Device Token and Payload

In the platform’s cloud messaging service, the server sends a message not to a device but to a device token that identifies the app on that device. The token belongs to the installation of the app and can be refreshed over time; when the app receives a new token, it must report it to the server. A message sent to a token from before the refresh finds no receiver on the new installation.

The payload of a message comes in two kinds. The data payload carries the app’s own keys and values and arrives at the app’s receiver class; the app decides what to do with it. The notification payload carries a title and a text, and while the app is in the background the platform can show it on its own as a notification. The channel and the permission of the previous lesson are at the gate on this second path as well.

// Platform code: OrderMessageReceiver
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage

class OrderMessageReceiver : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        Log.d("orders", "token refreshed")
        sendTokenToServer(token)
    }

    override fun onMessageReceived(message: RemoteMessage) {
        val orderId = message.data["order"]
        showOrder(orderId)
    }
}

The service’s receiver class defines two methods. onNewToken is called when the token is refreshed, and onMessageReceived hands a message that arrived with a data payload to the app. This part is platform code, and we claim no output, duration or arrival count for it here. The model builds this rule of the platform; the model is not the platform itself.

Data Payload and Notification Payload

The difference between the two payloads is who will handle the message. The data payload carries only key and value pairs; the platform does not try to show it, and hands it to the app’s receiver class. The app decides for itself what to do at that moment: it can show a notification, update a local database, or do nothing at all. The notification payload carries a title and a text. While the app is in the background, the platform can show this payload on its own as a notification, and in that case the app’s code does not run.

This distinction has a consequence for delivery. When a message with a data payload reaches the receiver, the receiver’s code runs, and arrival means appearing in the receiver’s list; the model of this lesson defines arrival this way too. On the path where the platform shows a notification payload, the message may never pass through the receiver’s code. The channel and permission gates of the previous lesson apply on that path as well: a notification payload that falls on a closed channel or an ungranted permission does not reach the user, even if the message left the service. This is why there are three separate gates between “the server sent the message” and “the user saw the message”: the service, the receiver class and the notification gates.

Collapse Key

While the device is offline, the server can keep sending messages. The service holds them for the device. If the sender has put a collapse key on a message, then of the pending messages that carry the same key, only the newest is delivered when the device comes online. This is useful for messages about earlier states: if an order was updated three times, the user does not need to see the first two.

In the model, a message is the Message class. If there is a collapse key, it is the key of the event. If there is not, the message is known only by its own ID and the ID becomes the key; so it has no counterpart to be collapsed.

// Message.kt — builds the message the server sends; with no collapse key, the ID becomes the key
class Message(val id: String, val seq: Int, val collapseKey: String? = null) {
    fun toSignal(): Signal = Signal(collapseKey ?: id, seq)
}

Among the messages that pile up while the device is offline, only the last of those with the same key remains.

// Offline.kt — keeps only the last message of each key among those piled up on an offline device
fun collapseOffline(pending: List<Signal>): List<Signal> =
    pending.filter { s -> pending.none { it.key == s.key && it.seq > s.seq } }

We send five messages. The order status three times, and a chat message and a daily news item once each.

// Main.kt — of the messages piled up under the same collapse key on an offline device, only the last arrives
fun main() {
    val messages = listOf(
        Message("m1", 1, "order.42"),
        Message("m2", 2, "message.deniz"),
        Message("m3", 3, "order.42"),
        Message("m4", 4, "order.42"),
        Message("m5", 5, "news.daily"),
    )
    val sent = messages.map { it.toSignal() }
    val received = collapseOffline(sent)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
received seqs: [2, 4, 5]
event-1: key=order.42 | copies=0 -> superseded
event-2: key=message.deniz | copies=1 -> once
event-3: key=order.42 | copies=0 -> superseded
event-4: key=order.42 | copies=1 -> once
event-5: key=news.daily | copies=1 -> once
once: 3
duplicated: 0
superseded: 2
lost: 0

This lesson’s delivery is superseded. Updates 1 and 3 of the order status were never delivered, but update 4 arrived with the same key and took their place. The user sees only the latest status. The two superseded events are two of the three messages under the order key.

The origin we measured in K04 told us from which step of the chain the answer to a read came. Superseded is not an origin: it does not say where an answer came from, it says that another event took the place of an event. There a read always found an answer; here an event may never arrive.

SC1. The model builds the interval in which the device is offline as a single moment of accumulation: the messages pile up and are delivered in one move. On the platform the device can connect intermittently, messages can enter a delivery round more than once, and which message goes when belongs to the service; none of these exist in the model.

Time to Live

A message has a time to live (TTL). When the time runs out, the service no longer delivers the message. If the device does not come online within this time, the message does not arrive. In the model, time is not a real clock but a step in the same unit as the sequence number: the message is sent at step seq, the device connects at step onlineAt, and the time to live is an integer number of steps. These numbers are made-up values of the model; they are not the real durations of the platform.

The function below drops the messages whose time to live has run out when the device connects.

// Main.kt — a message whose time to live runs out before the device comes online does not arrive
fun expire(sent: List<Signal>, onlineAt: Int, ttl: Int): List<Signal> =
    sent.filter { onlineAt - it.seq <= ttl }

fun main() {
    val messages = listOf(
        Message("m6", 6, "news.sports"),
        Message("m7", 7, "order.43"),
        Message("m8", 8, "reminder.medication"),
        Message("m9", 9, "message.nur"),
        Message("m10", 10, "order.43"),
    )
    val sent = messages.map { it.toSignal() }
    val received = collapseOffline(expire(sent, onlineAt = 13, ttl = 4))
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
received seqs: [9, 10]
event-6: key=news.sports | copies=0 -> lost
event-7: key=order.43 | copies=0 -> superseded
event-8: key=reminder.medication | copies=0 -> lost
event-9: key=message.nur | copies=1 -> once
event-10: key=order.43 | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 2

Three messages had their time to live run out, and their results differ. News item 6 and reminder 8 were lost: neither a copy of them nor a later event with the same key arrived. Order 7, on the other hand, was counted as superseded even though its time ran out, because update 10 arrived with the same key. The rule does not see that the message expired; it looks only at whether it arrived and whether an event took its place.

SC2. The model builds the time to live as a single integer and removes the expired message from the list. On the platform the upper limit of the time to live depends on the service, and how a value above the limit is handled is not in the model.

At-Least-Once Delivery

Collapsing and the time to live reduce messages. A third behavior of the service can multiply them. When the service delivers a message to the device, it waits for an acknowledgement; if none comes, it sends the message again. This arrangement is called at-least-once delivery: the message is never lost, but it can arrive more than once. Its opposite is at-most-once delivery: the message arrives at most once, but it may never arrive. The service offers the first because it chooses to prevent loss, and leaves the cost of duplication to the receiver.

In the model, resending is a copy of the sequence numbers that received no acknowledgement being added to the end of the list.

// Retry.kt — the service sends once more the message that received no acknowledgement
fun retry(sent: List<Signal>, unacked: List<Int>): List<Signal> =
    sent + unacked.map { seq -> sent.first { it.seq == seq } }

We send four messages; two of them receive no acknowledgement. The messages have no collapse key, so their IDs become the keys.

// Main.kt — two messages with no acknowledgement arrive as two copies when resent
fun main() {
    val messages = listOf(
        Message("m16", 16),
        Message("m17", 17),
        Message("m18", 18),
        Message("m19", 19),
    )
    val sent = messages.map { it.toSignal() }
    val received = retry(sent, unacked = listOf(17, 18))
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
received seqs: [16, 17, 18, 19, 17, 18]
event-16: key=m16 | copies=1 -> once
event-17: key=m17 | copies=2 -> duplicated
event-18: key=m18 | copies=2 -> duplicated
event-19: key=m19 | copies=1 -> once
once: 2
duplicated: 2
superseded: 0
lost: 0

Messages 17 and 18 arrived as two copies and were counted as duplicated; 16 and 19 stayed once with a single copy. Although this lesson’s delivery is superseded, the same service can also produce duplicated; the category depends not on the message but on the event the message went through while passing the service. The receiver’s remedy is deduplication: it stores the message ID and does not process it the second time. This is the same arrangement that we will measure separately, with a handled marker, in the deep links lesson.

SC3. The model builds the missing acknowledgement as a certain event for a single sequence number, repeated once. On the platform the number and interval of resends belong to the service, and why the acknowledgement did not come is not in the model.

Common Misconceptions

“A collapse key moves a message forward.” A collapse key changes neither the ordering nor the priority; it only decides which of the pending messages with the same key will remain. Priority is a separate setting.

“A message whose time to live has run out still arrives late.” When the time to live runs out, the service does not deliver the message. This is why messages 6 and 8 were lost in the measurement; there was no copy arriving late. For a message that has to arrive, the time to live must be chosen longer than the time the device can stay offline.

“A collapsed message has been deleted, so there is a bug.” The place of a superseded message has been taken by a newer message with the same key. This is not a bug but the behavior the sender asked for. The bug is giving the same key to two different topics: in that case, even if one of them is really needed, the other takes its place.

“The device token is a permanent identity.” The token can change when the app is reinstalled or the user clears the data. If the server keeps the old token, the messages it sends reach no device, and on the server side this can look like a successful send. The receiver side’s job is to report the new token to the server.

“If the server sent the message, the user sees it.” A message that leaves the server has to pass the gates of the service, the receiver class and the notification. The rule does not see at which of these gates it was lost; it only counts the result.

The Bounding Measurement

Collapsing does not work for every message: it merges only the messages that carry a collapse key. When messages with no key pile up, none of them takes the place of another.

// Main.kt — messages that carry no collapse key each arrive after piling up
fun main() {
    val messages = listOf(
        Message("chat.1", 11),
        Message("status.1", 12, "status.online"),
        Message("chat.2", 13),
        Message("status.2", 14, "status.online"),
        Message("chat.3", 15),
    )
    val sent = messages.map { it.toSignal() }
    val received = collapseOffline(sent)
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
event-11: key=chat.1 | copies=1 -> once
event-12: key=status.online | copies=0 -> superseded
event-13: key=chat.2 | copies=1 -> once
event-14: key=status.online | copies=1 -> once
event-15: key=chat.3 | copies=1 -> once
once: 4
duplicated: 0
superseded: 1
lost: 0

The three chat messages, because their IDs differ, arrived one by one and once after piling up. Of the two status messages that carry the same collapse key, only the second arrived; the first was superseded. For chat this is the right behavior: every message wants to be read. For status it is right too: there is no point in the user hearing twice that someone is online. Which message gets a key is the sender’s decision, and the rule counts only the result.

SC4. The model assumes the ID of a message without a key is unique. On the platform it is possible for the sender to give two different messages the same ID, and what the service would do in that case is not in the model.

Summary

  • This lesson’s delivery: superseded. Of the messages piled up on an offline device under the same collapse key, only the last arrives, and the earlier ones leave their place to another message.
  • The device token identifies the installation of the app; the payload of a message is a data or notification payload, and the channel and permission gates of the previous lesson apply on the second path as well.
  • Of five messages, two were superseded: the first two updates of the order status. This is a delivery, not an origin; an event’s place has been taken by another event.
  • Two messages whose time to live ran out were lost and one was superseded: lost 2, superseded 1. The rule sees whether a message arrived, not that its time ran out.
  • Three messages with no collapse key arrived once; of two status messages with the same key, one was superseded.
  • The service delivers at least once: two messages with no acknowledgement became duplicated, duplicated 2. The receiver’s remedy is deduplication.

Next Step

In this lesson the event left the server and went to the device by a single path. In the location provider the event is produced inside the device, continuously and often; the receiver may not be able to process all of it. The next lesson, Location and Maps, measures which location update the minimum interval rule drops and what happens to later updates when the permission is revoked.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close