Skip to content
C.W.K.
Lesson 05 of 06 · published

Offline Copies That Cannot Lie

~14 min · offline, concurrency, caching, swift

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Two stores with opposite rules

The phone keeps two kinds of data, and they obey opposite rules. The outbox from lesson 3 is authored data: plates Dad made that the engine has not acknowledged yet. Nothing may drop them silently. The offline shelf holds disposable copies of what the engine already said: the plate list, each plate's detail, photo bytes, the tree, the classes and the threads. The shelf's own doc comment states the order between them. The copies are disposable, the outbox stays authored data, and only a successful copy receipt lets the outbox retire bytes the engine has acknowledged.

Each copy is one file holding the response body and the moment it was saved, so a screen that is showing a copy can say so: "Saved on device" with the date and time. A broken file reads as missing, and the next good read replaces it.

Scoped to one engine

The shelf's directory is named from the engine's base URL. Copies taken from one engine can never appear while the phone talks to another. When Dad changes the engine address in Settings, the app retires the old shelf before it saves the new address, opens a new shelf for the new engine, clears the image cache, and restores the plates and the tree from whatever that shelf already holds. For an engine it has never visited, that is nothing. A hosted test switches to a second engine and finds an empty screen, then switches back and finds the first engine's plate again.

Tickets and generations

The hard case is time. A read can be slow, and while it is in flight Dad can change the very record it will bring back. If the late reply were written as it arrived, the device copy would quietly undo his change. The shelf prevents that with two counters and a lock.

  • begin(path) records a fresh id as the latest read of that path and returns a ticket holding that id and the shelf's current generation.
  • didMutate() bumps the generation. retire() marks the shelf retired and bumps it too.
  • isCurrent is true only if the shelf is not retired, the generation has not moved, and this ticket is still the latest read of its path.
  • A write that carries a ticket lands only if that ticket is current.

EngineClient applies the rule in two places. Every read of a mirrored path (the plate list, a plate, the tree, a class, a thread) takes a ticket before the request goes out. When the answer arrives and decodes, a stale ticket throws a cancellation, so the screen keeps what it had, and a current one writes the copy. Every mutation does the opposite: after the engine answers a correction, an adoption or a question, the client bumps the generation and writes the mutation's own answer straight onto the shelf. A changed plate also updates its row in the saved list.

So the freshest truth is always the answer to the change, and any read that started before it is refused. A later read of the same path also supersedes an earlier one, because each begin replaces the path's read id. Two hosted tests pin both cases: a slow list read that finishes after a write cannot erase the device record, and a late reply from the old engine cannot fill the new engine's screen.

Sync reads, never asks

While the app is active, a loop runs syncOffline every 60 seconds. It drains the outbox first. If the plate list read succeeded, it reads the tree, then every listed plate's detail, and the thumbnail and display bytes of each photo. Next come the classes reachable from the tree and from the plates' links, following parents and children until none is new, and finally the threads: the kitchen, every plate's and every visited class's. Each landed unit is kept even if a later one fails.

Every one of those calls is a GET. The test that seeds a cold offline launch asserts exactly that, because a sync must never ask Pippa for a new reading or a new answer. Photos never change once stored, so a saved image is returned without a request as long as its bytes still decode as an image.

Pages that cannot lie either

The plate list is paged, 500 at a time. The client walks pages until one comes back short. If a later page only repeats ids it already has, it stops with a message saying the saved copy was kept. The whole list is written to the shelf only after every page has arrived, and only with a current ticket. A second page that fails halfway leaves the previous complete copy in place; the test uses 501 plates to prove it.

The outbox retires after the copy

When the engine acknowledges a new plate, the drain's send bumps the generation, saves the plate's detail, and writes every uploaded photo to the shelf, under the capture's own path and as the thumbnail and display copy of the engine's photo ids. Those are the phone's own downscaled bytes, so the new plate shows offline at once. If any of those writes fails, the send reports a transient failure saying the engine has the plate but the device copy could not be saved. The plate stays queued, and the next pass is a replay the engine answers from its stored plate. The web keeps none of this. It shows only live answers, and its local storage holds only the Sidekick's preferences: whether it was open, its mode, and the frame's three sizes (side width, dock height and floating size). No plate and no reading is kept there.

Principle: A cache may be stale, but it must never be newer-looking than the truth. Number your changes, stamp every read with the number it started under, and refuse any write whose number has moved.

Code

cwkSizzle, clients/apple/SizzleMobile/Sources/OfflineShelf.swift: one shelf per engine, tickets, generations and retirement (verbatim, lines 14-34)·swift
    init(root: URL, endpoint: EngineEndpoint) {
        directory = root.appendingPathComponent(OfflineKey.filename((try? endpoint.baseURL().absoluteString) ?? "invalid"), isDirectory: true)
    }

    struct Ticket { let generation: Int; let id: UUID }

    func begin(_ path: String) -> Ticket {
        lock.withLock {
            let id = UUID()
            reads[path] = id
            return Ticket(generation: generation, id: id)
        }
    }

    func isCurrent(_ ticket: Ticket, path: String) -> Bool {
        lock.withLock { !retired && ticket.generation == generation && reads[path] == ticket.id }
    }

    func didMutate() { lock.withLock { generation += 1 } }
    func retire() { lock.withLock { retired = true; generation += 1 } }
    var hasWriteFailure: Bool { lock.withLock { !failedPaths.isEmpty } }
cwkSizzle, clients/apple/SizzleMobile/Sources/EngineClient.swift: reads write with a ticket, mutations bump the generation and save their own answer (verbatim, lines 210-236)·swift
    private func get<T: Decodable>(_ path: String) async throws -> T {
        let mirrored = path == "/api/encounters" || path == "/api/tree"
            || path.hasPrefix("/api/encounters/") || path.hasPrefix("/api/classes/") || path.hasPrefix("/api/threads/")
        let ticket = mirrored ? shelf?.begin(path) : nil
        let reply = try await transport(try request("GET", path))
        let value: T = try decode(reply)
        try Task.checkCancellation()
        if let ticket, let shelf {
            guard shelf.isCurrent(ticket, path: path) else { throw CancellationError() }
            shelf.write(reply.data, path: path, ticket: ticket)
        }
        return value
    }

    private func send<T: Decodable>(_ method: String, _ path: String, json: [String: String]? = nil) async throws -> T {
        var request = try request(method, path)
        if let json {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try JSONSerialization.data(withJSONObject: json)
        }
        let value: T = try decode(try await transport(request))
        shelf?.didMutate()
        if let detail = value as? EncounterDetail { shelf?.remember(detail) }
        if let detail = value as? ClassDetail { shelf?.save(detail, path: "/api/classes/\(detail.class.id)") }
        if let reply = value as? AskReply { shelf?.save(reply.thread, path: "/api/threads/\(Self.escape(reply.thread.key))") }
        return value
    }
cwkSizzle, clients/apple/SizzleMobile/Tests/OfflineTests.swift: a read begun before a mutation cannot write over it (verbatim, lines 158-167)·swift
    func testOldReadsCannotReplaceACompletedMutation() async throws {
        let app = model(online().transport)
        let shelf = try XCTUnwrap(app.client.shelf)
        let before = shelf.begin("/api/encounters")
        shelf.didMutate()
        let detail = try JSONDecoder().decode(EncounterDetail.self, from: Data(Self.detail.utf8))
        XCTAssertTrue(shelf.remember(detail))
        XCTAssertFalse(shelf.write(Data("{\"encounters\":[]}".utf8), path: "/api/encounters", ticket: before))
        XCTAssertEqual(model(online().transport).plates.count, 1)
    }

External links

Exercise

Add device copies to a client of yours that reads records and can also change them. Keep one directory per server address. Give the cache a generation counter and a latest-read id per path. Every read takes a ticket before its request, and writes its copy only if the ticket is still current. Every successful change bumps the generation and writes the change's own answer into the cache. Then write the test that matters: start a read, hold its reply, perform a change, release the reply, and assert the cached record still shows the change.
Hint
An actor or a continuation you release by hand makes a reply wait on cue, as the suite's delayed reply does. Switch servers in a second test while a reply is held, and assert the new server's screen stays empty. Keep the user's own unsent data in a separate store with stricter rules than the cache.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.