Skip to content
academia.sh

Lesson 12 / 14

Caching and Offline Support

In the single source of truth pattern the UI reads only the local database, and the network only refreshes the database. The lesson measures the origin of a read after a refresh, that the origin stays the same while the value goes stale when the network goes down, and what a key that is not on disk at all falls to.

Published Updated

In this lesson

  • Explains the single source of truth pattern as the UI reading only the local database
  • Shows that the network only refreshes the database and does not enter the chain of the read
  • Measures the origin of a read after a refresh as disk
  • Shows that when the network goes down the origin stays disk, the value goes stale, and a key not on disk falls to default
Contents

In the previous lesson the response from the network was decoded and became a tier in the chain; the cache was also the decoded form of an old response. In this lesson we examine not the response sitting in the chain but its being written permanently to disk. If the network response is written to disk, the screen has no need to look at the network at all, and when the network goes down the screen keeps working. The question of the lesson is the same as in the earlier lessons: where does the answer to this read come from?

// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}

The Single Source of Truth

The single source of truth pattern sets two rules. The UI reads only the local database; it never looks directly at the network. The network only refreshes the database; it gives no data directly to the UI. Data flows in one direction: from the network to the database, from the database to the UI. This way the screen is fed from a single place, and that place works even without a network.

// Platform code: ProductRepository
class ProductRepository(private val dao: ProductDao, private val api: ProductApi) {
    fun products(): Flow<List<ProductEntity>> = dao.observeAll()

    suspend fun refresh() {
        try {
            dao.upsertAll(api.products())
        } catch (e: IOException) {
            // when offline, the old records stay
        }
    }
}

products only observes the database. refresh takes from the network and writes to the database, and if there is no network it writes nothing; when the database changes, the flow passes the new list to the UI. This piece is platform code, and here we make no claim about output, duration, or record count. The model builds this rule of the platform; the model is not the platform itself.

In the model, the refresh is a function that adds the keys requested from the network to the old map. If there is no network, the map does not change. The chain of the read has only disk in it; the network enters the chain of no read.

// Refresh.kt — writes the requested keys to disk if the network is reachable, leaves the disk as it is if it is not
fun refreshFrom(disk: Map<String, String>, network: Tier?, keys: List<String>): Map<String, String> {
    if (network == null) return disk
    return disk + keys.mapNotNull { key -> network.lookup(key)?.let { key to it } }
}

The screen reads three values and looks only at disk.

// Screen.kt — the UI reads only the disk
fun readProductScreen(disk: Map<String, String>): List<Read> {
    val chain = listOf(Tier("disk", disk))
    return listOf(
        Read("price", "product.price", chain, "unknown"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
    )
}

A Refresh Writes to Disk

The disk holds the price and the stock left over from the previous session. The server has updated the price. The reads of the screen before and after the refresh, side by side.

// Main.kt — the refresh writes the network value to disk, the read still comes from disk
fun main() {
    val disk = mapOf("product.price" to "120", "product.stock" to "9")
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8"))
    println("before refresh")
    readProductScreen(disk).forEach { it.show() }
    val refreshed = refreshFrom(disk, network, listOf("product.price", "product.stock", "campaign.code"))
    println("after refresh")
    val reads = readProductScreen(refreshed)
    reads.forEach { it.show() }
    countOrigins(reads)
}
before refresh
price: key=product.price | value=120 -> disk
stock: key=product.stock | value=9 -> disk
campaign: key=campaign.code | value=none -> default
after refresh
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 2
network: 0
default: 1

After the refresh the price became 125 and the stock 8, and the origin did not change: it is still disk. The network did not become the origin of a read; the network only changed the content of the disk. This lesson’s origin is disk: the screen reads from disk in every case, and the effect of the network shows only in the value on the disk. Because the server does not know the campaign code, that key was not written to disk and the read stayed at default.

DL1. The model assumes the refresh is a single step and entirely successful: the requested keys are either all written together or not written at all. A refresh being cut halfway, two refreshes overlapping, and a database transaction failing during the write are absent from the model.

When the Network Goes Down

If there is no network, refresh writes nothing. The old records stay, and the screen keeps showing them.

// Main.kt — when the network goes down the read still comes from disk but the value is stale
fun main() {
    val disk = mapOf("product.price" to "120", "product.stock" to "9")
    val refreshed = refreshFrom(disk, null, listOf("product.price", "product.stock", "campaign.code"))
    val reads = readProductScreen(refreshed)
    reads.forEach { it.show() }
    countOrigins(reads)
}
price: key=product.price | value=120 -> disk
stock: key=product.stock | value=9 -> disk
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 2
network: 0
default: 1

The origin of the read did not change: the price and the stock are disk, and the counts are the same as in the measurement before the refresh. What changes when there is no network is the value: the price stayed at 120, while on the server it is 125. The screen works offline, and the value it shows is stale.

This measurement differs from the staleness measure in M30/K04. In that course, the deviation of the value a screen showed from the value of the model was judged: the screen was either current or stale. In this lesson freshness is not judged; the rule says only which tier the read came from. In both measurements the price is 120 and the origin is disk; it is not the rule but the server value we put side by side that says the value is old.

The Bounding Measurement

What happens to a key that is not on disk at all? The application is running for the first time, the disk is empty, and there is no network. This is the most extreme form of the measurement in which the network goes down: there is not even an old value the screen could show.

// Main.kt — with an empty disk and no network every read falls to default, and when the network arrives it is written to disk
fun main() {
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8", "campaign.code" to "SUMMER10"))
    val keys = listOf("product.price", "product.stock", "campaign.code")
    val states = listOf("no-network" to null, "with-network" to network)
    for ((name, source) in states) {
        println("first launch: $name")
        val reads = readProductScreen(refreshFrom(emptyMap(), source, keys))
        reads.forEach { it.show() }
        countOrigins(reads)
    }
}
first launch: no-network
price: key=product.price | value=unknown -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 0
network: 0
default: 3
first launch: with-network
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=SUMMER10 -> disk
memory: 0
disk: 3
network: 0
default: 0

On the first launch without a network, three of the three reads are default. For a key that is not on disk, the network going down produces no value; the screen opens with the fallback values. When the network arrives, the refresh writes all three keys to disk, and the same reads this time come from disk. In the single source of truth, the network is not the origin of a read; it only makes the disk fill.

What the measurement does not say is which value is correct, and how the screen should look on a first launch without a network. The second is a design decision, and the rule does not give it.

DL2. The model assumes that the network is either never reachable or gives all the requested keys. A partial response, a slow connection, and when a refresh waiting for the network to return is retried are absent from the model.

Summary

  • This lesson’s origin: disk. In the single source of truth the UI reads only the database, and the network does not enter the chain of the read.
  • The refresh writes from the network to disk; even though after the refresh the price is 125 and the stock 8, the origin stays disk: disk 2, default 1.
  • When the network goes down the refresh writes nothing; the read is still disk, but the price stays at 120 and is stale.
  • Freshness is not judged here; the rule says not whether the value is current but which tier it came from.
  • A key that is not on disk at all is default on a first launch without a network: default 3; when the network arrives it becomes disk 3.

Next Step

In this lesson the screen read a single product, and the disk could hold it in one go. When the list grows, bringing all of it at once fits neither the network nor memory; the data is loaded piece by piece. The next lesson, Paged Data Loading, measures which origin an item on a page that has not yet been loaded is read from, and how the origin changes when the page is loaded.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close