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

Capture Never Waits

~14 min · outbox, offline-first, idempotency, swiftui

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

Invariant 1

A plate gets photographed where the food is, and that is often where the signal is not. Sizzle's first product invariant answers that directly: a photo is saved on the device and queued before any network call and before any call to Pippa, so losing the signal at the table loses nothing. The doc names its owner too. The phone's outbox holds the plate, and the engine makes the send safe to repeat by keying it on the client's request id. The same rule runs through the family's other capture apps; vesta-quest's Capture in Motion track and the outbox lesson in swift-quest's Talking to the Engine at Home teach it in general. This lesson is Sizzle's version, line by line.

Save is two steps

On the phone, Save in the New plate sheet does not send anything. It calls commit, which is quick, and only then starts a drain in its own task. If the commit fails, the sheet says "Not saved on this phone" with the reason, and nothing has left the device. If it succeeds, the sheet switches from Cancel to Close and shows the upload as it goes (lesson 4).

commit builds the payload: the kind, the note with its whitespace trimmed, the moment of capture, the device's time-zone identifier, and a place. The moment is the first photo's own camera clock when it has one, and the current time otherwise. The place is the first photo that carries GPS. Then it enqueues the plate under a fresh id minted with the prefix plate-. That outbox id is also the request_id the engine will see, which is what makes every later send idempotent.

The enqueue itself writes each JPEG into the app container under a new file name, with file protection that unlocks after the first unlock since boot, and then commits the kit's outbox document. If that document cannot be written, the files it just wrote are removed again. That cleanup is narrower than it looks. It covers only the outbox write: a photo write that throws halfway leaves the plate's earlier photos behind, because the loop runs before the cleanup is in place, and a process killed between the writes and the commit runs no cleanup at all. What the order does guarantee is the other direction. The record is written last, so it never names a file that is not there, and a leftover is at worst an unreferenced JPEG in the photo folder, never half a plate. Its comment puts the rule in four words: it exists from here on.

Time and place come from the original

Photos are prepared off the main actor. PreparedPhoto.prepare asks the kit's ImageDownscale for a JPEG whose long edge is at most 2048 pixels, and reads the metadata from the original: the capture time with its offset, and the GPS position with the signs that south and west need. Since kit todo #548 the downscaled JPEG also keeps the original's EXIF. Two older lines still say the upload has none, a comment in Outbox.swift and a sentence in the architecture doc. The code and a hosted test say otherwise.

The engine then decides. It takes the camera's own clock first, then the client's captured_at, then the current time, so a photo picked from the library days later keeps the evening it was eaten. A photo's own GPS wins over the place the client sent. The copies the engine derives for display and thumbnails are saved without EXIF or XMP, so the camera's GPS stays with the original, and the display copy is what Pippa sees. A JPEG comment is the one block that survives into both copies (track 5, lesson 5).

The drain

The drain is where sending happens, and its rules come from the kit's OutboxDrain, composed from three family outboxes that each got one part subtly wrong. A pass is serial, in capture order. A success counts as synced. A permanent failure moves that one plate to needs-attention and the pass continues. A transient failure puts the plate back and stops the pass, because the rest of the queue would fail the same way and a hundred pointless attempts cost battery on a bad link. Only some 4xx answers are permanent: 408, 425 and 429 ask to be retried, and 401 and 403 mean the session is wrong, not the plate.

Sizzle's own drain adds two things. It recovers sends that an earlier run left half-done, and it coalesces: a call that arrives while a pass is running asks for one more pass. Without that, a plate saved during a pass would wait for the next refresh, because the kit's pass takes its list when it starts. Drains start from Save, from pull to refresh on Plates, from a queued row's Send now, and from the sync loop that runs every 60 seconds while the app is active.

A parked plate is Dad's call

A plate the engine refused stays on the phone. Its row on Plates shows the reason after "Refused:", and a menu offers Send now and Discard this plate. That is the one visible way to act, with no swipe actions. A discard removes the outbox record first and the photo bytes second. A plate that is only waiting says "Waiting for the engine", with the last error when there was one.

The web has no outbox

The web's capture card keeps the picked files, the note and the request id in memory. The plate exists only once the engine has answered. A failure keeps all of it for Retry, but closing the tab loses it. The web sends the current time as captured_at and sends the originals as picked, so the engine still finds the camera's clock in the files themselves. The outbox lives on the side that carries the risk: the phone, at the table.

Principle: Make the save local and quick, and let sending be a separate act that may fail and repeat. Then the one thing the user did is never at the mercy of the network.

Code

cwkSizzle, clients/apple/SizzleMobile/Sources/Views/CaptureSheet.swift: Save commits on the phone, then starts the drain on its own (verbatim, lines 153-165)·swift
    /// Commit on this phone (quick), then send while the sheet shows it.
    private func save() async {
        committing = true
        defer { committing = false }
        do {
            let id = try await model.commit(kind: kind, note: note, photos: photos)
            error = nil
            sentId = id
            Task { await model.drain() }
        } catch {
            self.error = "Not saved on this phone: \(error.localizedDescription)"
        }
    }
cwkSizzle, clients/apple/SizzleMobile/Sources/AppModel.swift: a coalesced pass over the outbox (verbatim, lines 242-270)·swift
    /// A pass over the outbox. Coalesced: a call while a pass runs asks it
    /// for one more pass, because the kit's pass takes its list when it starts
    /// and a plate saved meanwhile would otherwise wait for the next refresh.
    func drain() async {
        guard !draining else {
            drainAgain = true
            return
        }
        draining = true
        defer { draining = false }
        repeat {
            drainAgain = false
            await outbox.recover()
            let client = self.client
            let outbox = self.outbox
            let pass = outbox.makeDrain { [weak self] entry in
                let photos = try await outbox.photoData(entry.payload)
                let id = entry.id
                return try await client.send(entry, photos: photos, outbox: outbox) { status in
                    Task { @MainActor in self?.noteUpload(id, status) }
                }
            }
            let outcome = await pass.drain()
            if outcome.synced > 0 { await outbox.forgetSynced() }
            await refreshPlates()
            if outcome.synced > 0 { revision += 1 }
            if outcome.haltedBecause != nil { break }
        } while drainAgain
    }
cwkSizzle, clients/apple/SizzleMobile/Tests/SizzleMobileTests.swift: no network, the plate stays; network back, it goes up once (verbatim, lines 194-217)·swift
    func testAnUnreachableEngineKeepsThePlateOnThePhone() async throws {
        let engine = ScriptedHTTPTransport { _ in throw URLError(.notConnectedToInternet) }
        let model = model(engine)
        try await model.capture(kind: .out, note: "", photos: [photo()])

        let entries = await model.outbox.entries
        XCTAssertEqual(entries.count, 1)
        XCTAssertEqual(entries[0].state, .queued)
        XCTAssertNotNil(entries[0].lastError)
        XCTAssertEqual(model.queued.count, 1)
        XCTAssertNotNil(model.platesError)

        // The network comes back: the same request id goes up, once.
        engine.script { request in Self.engine(request) }
        await model.drain()
        let after = await model.outbox.entries
        XCTAssertTrue(after.isEmpty)
        let ids = engine.calls.filter { $0.method == "POST" && $0.path == "/api/encounters" }.map { Self.requestId(in: $0.body) }
        XCTAssertEqual(ids.count, 1, "the first try never reached the plate's own request")
        XCTAssertEqual(Set(ids).count, 1)
        let keys = engine.calls.filter { $0.path == "/api/staged-uploads" }.map { Self.requestId(in: $0.body) }
        XCTAssertEqual(keys.count, 2, "the photo was tried, then sent once more under the same key")
        XCTAssertEqual(Set(keys).count, 1)
    }

External links

Exercise

Take one capture flow in a mobile app of yours, a note, a photo or a form. Split its Save into a local commit that returns quickly and a send that runs afterwards. Give each capture a client-minted id and send it as the request id, and make the server answer a repeated id with the record it already stored. Then classify failures: a refusal that will never succeed parks that one item for the user, anything else puts it back and stops the pass. Write one test that cuts the network, saves, restores the network and asserts the server saw the request exactly once.
Hint
Write the files first and the queue record second, so the record never names a missing file. Put the file writes inside the same cleanup that runs when the record fails, and sweep unreferenced files at start-up, because a crash between the two runs no cleanup at all. Treat 408, 429, 401 and 403 as retryable, not as a bad payload. A scripted transport that throws and then answers is enough to drive the test; no real server is needed.

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.