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

Fresh, and Only Once

~14 min · watchconnectivity, idempotency, distributed-systems, swift

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

An at-least-once lane, on purpose

The capture lane promises that a command arrives. It does not promise once, and it does not promise on time. That is not a bug in the lane; it is the price of the two things you wanted from it. Look at how a phone ends up holding the same command twice, or late:

  1. Every command normally arrives twice. The ring (sendMessage) wakes the phone app and hands it the envelope. Then the transfer, the durable record, delivers the very same envelope again. The kit has a name for the second copy: an echo.
  2. The watch re-offers what it still holds. On every session activation, every time the phone comes back into reach, and on every flush, anything still in the watch's outbox goes out again, until its transfer reports it landed. A completion callback lost to a relaunch means one more round.
  3. The watch waits for an absent phone. A command tapped while the phone is out of range sits in the outbox for minutes or hours and lands when the phone returns.

CwkKitWrist can drop honest echoes with a bounded ledger (WristSeenLedger), but its own header says that ledger is never the only defence: the consumer must be idempotent where it acts. Pathfinder passes no ledger at all and does the job in WatchBridge, where it can also do the second half.

Two checks at the door

WatchBridge.take(_:) runs five short lines before anything is obeyed. A presence (.watching) that came this way is ignored; lesson 3 explains why it should never have come. An id already seen is dropped. The id is remembered (the last 64). And a command that is not fresh is dropped: WristCommand.maxAge is 30 seconds, measured as now - issuedAt on the phone's clock against the watch's stamp, with the same 30 seconds allowed the other way for skew. The comment in PathfinderKit puts the intent better than a spec could: a stale "start a walk home" from this morning is history, never an instruction.

Notice the order. The id is remembered before freshness is judged, so a stale command's echo is dropped as a duplicate and never gets judged a second time. And the memory is small and lives in the process ("command ids obeyed in this run"). Within one run that is enough, because freshness bounds how long a duplicate could matter at all: once a command is older than 30 seconds, the freshness check throws it away whether or not anyone remembers its id, and sixty-four ids is far more than a person taps in 30 seconds.

Across a relaunch it is not enough, and it is worth saying so plainly. If the phone app dies and comes back between the ring and the transfer's echo, inside those 30 seconds (iOS reclaims a suspended app, or it crashes), the new process remembers no ids, the echo is still fresh, and the command runs a second time. A second end does nothing when no trip is left to end (if trip != nil). A second toggleVoice flips the voice straight back, and a second go can start the route again. The code accepts that window rather than persisting its ledger, and no test covers it. So the honest contract is once per id within a run, while fresh: two checks that shrink the duplicate window to 30 seconds and one process, not a guarantee of exactly once.

Why the receiver judges freshness

You might ask why the watch does not simply refuse to send old commands. Because it cannot know. The whole point of a durable outbox is that the sender does not know when its record will land. Only the receiver holds "now" at the moment of acting, so only the receiver can decide whether the record is still an instruction.

Durability and freshness answer different questions. Durability: did the record reach the phone? Freshness: is it still a command? Drop the second check and durability turns a tap into a delayed fuse. Picture Dad tapping "walk home" on a bench with the phone left behind. Hours later the phone comes back into range, the outbox flushes, and the phone ends whatever trip he is on (goFromWrist really does end the current guidance first, "switched from the watch") and routes him home from somewhere else entirely.

Obeyed is not the same as done

A fresh, new command can still meet a no. go names a place by id; if that place is no longer saved on the phone, the wrist is told "That place is not saved on the iPhone." Starting a route also needs a fix to route from, and Pathfinder asks for location When In Use only, never Always: with the phone app not on screen and no segment already measuring, the phone may not start measuring now, so the wrist is told "Open Pathfinder on the iPhone to start." Either sentence rides the next glance's problem field, so the answer appears where the question was asked. And pull changes nothing: the phone publishes its glance at once and touches nothing else (lesson 5).

The one message that did not belong here

The first version of the wrist put one more thing on this lane: every 30 seconds, while its navigation session ran, the watch said so with link.capture(.watching(true)). Run the three questions from lesson 1 over that heartbeat. A duplicate says nothing new. A late one has nothing the newest beat did not already say, and on its own it lies, because it says "live" about a session that may be long gone. A lost one is repaired by the next beat. Every answer says state, and it was riding a lane built for acts. On the bench it piled up exactly as this lesson predicts, and commit fc2558d moved it to a lane of its own (lesson 3). The phone still refuses a presence that arrives this way: "a presence that came the capture way is no command", as its test puts it.

A durable outbox needs an expiry on the receiving side. The sender makes the record survive; only the receiver can decide that a surviving record is no longer an instruction. Pair at-least-once delivery with once-per-id on arrival and a freshness window, and the lane can be as stubborn as it likes, as long as the receiver remembers its ids for the whole window. A memory that a restart can wipe inside the window leaves exactly that gap open.

Code

PathfinderKit, Wrist/WristCommand.swift: maxAge and isFresh, verbatim·swift
  /// A command older than this when it reaches the phone is not obeyed.
  public static let maxAge: TimeInterval = 30
  /// How often the watch says its session is still live, and how long the
  /// phone believes it.
  public static let watchingEvery: TimeInterval = 30
  public static let watchingTrust: TimeInterval = 75

  /// Fresh, judged by the watch's own clock against the phone's (the pair's
  /// clocks agree to the second; a little skew either way is allowed).
  public func isFresh(at now: Date = Date()) -> Bool {
    let age = now.timeIntervalSince(issuedAt)
    return age <= Self.maxAge && age >= -Self.maxAge
  }
WatchBridge.swift (the iOS app): take, verbatim·swift
  func take(_ command: WristCommand) {
    // A presence is the watch's application context, never a command.
    if case .watching = command.action { return }
    guard !obeyed.contains(command.id) else { return }
    obeyed.append(command.id)
    if obeyed.count > 64 { obeyed.removeFirst(obeyed.count - 64) }
    guard command.isFresh() else { return }
    onCommand?(command.action)
  }
A minimal sketch: an echo, a late arrival and a skewed clock at the phone's door (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch of the phone's door for wrist commands: an at-least-once,
// possibly-late lane, made safe by two checks on the RECEIVING side.
struct Command {
  let id: String
  let issuedAt: TimeInterval  // the watch's clock
  let action: String
}

struct Door {
  static let maxAge: TimeInterval = 30
  static let remembered = 64
  var seen: [String] = []
  var obeyed: [String] = []

  mutating func take(_ command: Command, now: TimeInterval) -> String {
    guard !seen.contains(command.id) else { return "echo, dropped" }
    seen.append(command.id)
    if seen.count > Self.remembered { seen.removeFirst(seen.count - Self.remembered) }
    let age = now - command.issuedAt
    guard age <= Self.maxAge, age >= -Self.maxAge else { return "stale (\(Int(age)) s), dropped" }
    obeyed.append(command.action)
    return "obeyed"
  }
}

var door = Door()
let end = Command(id: "wc-1", issuedAt: 1_000, action: "end")
let walkHome = Command(id: "wc-2", issuedAt: 1_010, action: "go(home, walking)")
let mute = Command(id: "wc-3", issuedAt: 1_103, action: "toggleVoice")

let arrivals: [(String, Command, TimeInterval)] = [
  ("ring (message)", end, 1_001),
  ("transfer echo", end, 1_002),
  ("transfer, phone was away", walkHome, 1_010 + 3 * 3600),
  ("ring, watch clock 3 s fast", mute, 1_100),
]
for (lane, command, now) in arrivals {
  print("\(command.action.padding(toLength: 18, withPad: " ", startingAt: 0)) via \(lane): \(door.take(command, now: now))")
}
print("obeyed: \(door.obeyed)")

// Output:
// end                via ring (message): obeyed
// end                via transfer echo: echo, dropped
// go(home, walking)  via transfer, phone was away: stale (10800 s), dropped
// toggleVoice        via ring, watch clock 3 s fast: obeyed
// obeyed: ["end", "toggleVoice"]

External links

Exercise

Find a queue, retry loop or offline outbox in a project of yours: a job queue, a webhook consumer, a mobile app's pending-uploads list. For one message type, write down (a) what happens if it is delivered twice, (b) what happens if it is delivered six hours late. If either answer is "something wrong", add the missing check on the receiving side: an id it has already handled, a timestamp it compares with its own clock, or both. Write a test that delivers the same message twice and one that delivers it late.
Hint
Decide the freshness window from the action, not from the transport. "Stop the music" is stale in seconds; "save this note" is never stale and needs only the id check. If you dedupe in memory, a restart forgets every id at once: ask whether a duplicate can still arrive fresh after a restart, and if it can, persist the ids for at least the length of the window, or make the action itself idempotent.

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.