0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【開発日誌 Day3】Outboxを書き直したら、送信失敗の"再送漏れ"が消えた

0
Posted at

Captio式シンプルメモ開発日誌
2026年5月12日

「送信ボタンを押した後」はユーザーに見せるUIではない。バックグラウンドの話でもない。実は「アプリが落ちても、機内モードでも、明日の朝でも、必ず届く」という契約の話だ。前回(Day2)では送信成功トーストを消すまでを書いた(Day2の記事)。今回はその一歩裏側、Outboxアーキテクチャを3回書き直した記録になる。

なぜ"送信"はそんなに難しいのか

送信処理は、見た目より遥かに難しい。理由は3つある。

第一に、iOSはアプリをすぐsuspendする。送信中にホームに戻られると、URLSessionのcontinuationがそのまま消える可能性がある。Background URLSessionに切り替えるなら別だが、軽量メモアプリでBackground URLSessionを選ぶと、daemonとの通信オーバーヘッドで起動が0.5秒以上重くなる。だからフォアグラウンドで完結させたかった。

第二に、ネットワークが揺れる。地下鉄、エレベーター、混雑したカフェ。送信タップから1秒後に圏外になる確率は無視できない。実測ではメトロ車内の送信失敗率が平日朝で12%程度あった。

第三に、Captio式シンプルメモ特有の事情がある。このアプリは「送ったら画面から消える」というデザインだ。テキストは0.25秒のページめくりアニメと共にビューから抜ける。にもかかわらず「実は送信失敗していました」と後から言われるのは、想定する体験の真逆だ。送ったテキストは、たとえアプリが落ちようと、必ずいつかどこかに届かないと、信頼そのものが崩れる。

「outbox swift 実装」で検索すると、業務系バックエンドの解説は無数に出てくるが、iOSローカルでの実装例はほとんど出てこない。だから自分で書くしかなかった。最初の実装は2週間で出したが、結局3回書き直すことになる。

Outboxパターンとは何で、なぜiOSに必要なのか

Outboxパターンとは、業務系のバックエンドで使われる「DBに永続化したアウトプットを別ワーカーが拾って外部に送る」設計だ。元はマイクロサービス間の「メッセージとDB更新の二相性問題」を解くために生まれた。Pat Hellandの2007年の論文を起源にする説が有力で、2010年代に分散DB界隈で広まった。

これをiOSのローカルに持ち込む。Captio式シンプルメモのOutboxはこう動く。

  1. ユーザーが送信タップ
  2. Core DataにOutboxItemを即座に永続化
  3. UIはすぐに次の状態(空のメモ画面)に戻る
  4. バックグラウンドワーカーがpendingなOutboxItemを順にRelay APIに送る
  5. 成功したらstate = .sent、失敗したらattemptCount += 1と指数バックオフで再送

ポイントは「UIの反応速度」と「送信の確実性」を別のレイヤーで独立に最適化できることだ。送信が遅くてもUIは速く、送信が失敗してもユーザーは知らずに次のメモを書ける。逆にこの2つを同じ画面処理で扱おうとすると、必ず破綻する。送信完了を待つUIはネットワーク次第で2秒〜10秒の間で揺れ、ユーザーの集中を破壊する。

ところがOutboxを挟むと、UIの速さは「Core Dataにinsertした瞬間」で決まる。SQLiteのinsertは数msで終わるので、UIは常に150ms以内に戻れる。送信の成否は背後のRunnerが面倒を見る。これは2層構造の一種で、業務系のCQRSにも似ているが、iOSではもっと素朴に「保存」と「送る」を分ける形になる。

Core Dataとactor-isolationはどう組み合わせるのか

Swift Concurrencyが標準化されてから、Core Dataの扱い方は大きく変わった。NSManagedObjectContext.performをネストする旧スタイルから、@ModelActorや独自actorで包む新スタイルへ移行している。Swift 6のstrict concurrencyを有効にすると、旧スタイルの多くは警告かエラーになる。

Captio式シンプルメモのOutboxはこう書いてある。

import CoreData

actor OutboxStore {
    private let container: NSPersistentContainer
    private let context: NSManagedObjectContext

    init(container: NSPersistentContainer) {
        self.container = container
        self.context = container.newBackgroundContext()
        self.context.automaticallyMergesChangesFromParent = true
        self.context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    func enqueue(body: String, locale: String) async throws -> UUID {
        let id = UUID()
        try await context.perform {
            let item = OutboxItem(context: self.context)
            item.id = id
            item.body = body
            item.locale = locale
            item.state = OutboxState.pending.rawValue
            item.attemptCount = 0
            item.nextAttemptAt = Date()
            item.createdAt = Date()
            try self.context.save()
        }
        return id
    }

    func pending(limit: Int = 16) async throws -> [OutboxSnapshot] {
        try await context.perform {
            let req = OutboxItem.fetchRequest()
            req.predicate = NSPredicate(
                format: "state == %@ AND nextAttemptAt <= %@",
                OutboxState.pending.rawValue, Date() as NSDate
            )
            req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
            req.fetchLimit = limit
            let items = try self.context.fetch(req)
            return items.map { OutboxSnapshot(from: $0) }
        }
    }
}

ここで重要なのは2点ある。

1つ目はactorOutboxStore全体を包んだこと。これでCore DataのMOCを別スレッドから誤って触る事故が、コンパイル時に防がれる。Swift 5.5以前はランタイムで「This NSManagedObjectContext's coordinator has no persistent stores」のようなクラッシュが頻発していたが、actorで囲うとそもそも誤った呼び出しが書けなくなる。

2つ目はOutboxSnapshotという値型をCore Dataの外に返していること。NSManagedObjectはcontextに強く紐づくので、actorの外に渡すと使えなくなる。だから「IDと内容だけを持つstruct」へ変換してから返す。これはSwift Sendableの制約上、必要な変換だ。OutboxSnapshotSendableにしておくと、actor境界を越えて安全に持ち運べる。

nextAttemptAtで再送を制御する

nextAttemptAtというフィールドが、地味だが効くミソだ。今すぐ送るアイテムだけをpendingとして取り出すのではなく、「nextAttemptAt <= now」のものだけ取り出す。失敗したらこの値を未来に動かせば、自動的にバックオフが効く。

func markFailed(id: UUID, error: Error) async throws {
    try await context.perform {
        let req = OutboxItem.fetchRequest()
        req.predicate = NSPredicate(format: "id == %@", id as CVarArg)
        guard let item = try self.context.fetch(req).first else { return }
        item.attemptCount += 1
        let backoff = self.backoffSeconds(for: Int(item.attemptCount))
        item.nextAttemptAt = Date().addingTimeInterval(backoff)
        item.lastError = String(describing: error).prefix(512).description
        if item.attemptCount >= 10 {
            item.state = OutboxState.failed.rawValue
        }
        try self.context.save()
    }
}

private func backoffSeconds(for attempt: Int) -> TimeInterval {
    let base = pow(2.0, Double(min(attempt, 10))) // 2, 4, 8, 16, ...
    let jitter = Double.random(in: 0...(base * 0.2))
    return min(base + jitter, 3600) // 上限1時間
}

指数バックオフ+ジッタは分散システムの常識だが、iOSのローカルでも効く。理由は「圏外復帰時に、複数のOutboxItemが全部同時に再送を試みる」現象を回避できるからだ。10件まとめて即時再送すると、Relay API側で429を返されることがある。最初に書いたコードはjitter無しの純粋な指数バックオフだったが、地下鉄を出た瞬間に5件のメモが同時送信されて、サーバーが3件ほど失敗で返してきた。

ジッタを入れた瞬間にこの現象は消えた。20%という数字は経験則で、最小5%でも効くが、安全側で20%にした。

再送時の冪等性をどう担保するか

再送する以上、サーバー側で重複が発生する可能性がある。だからOutboxItemのidをそのままIdempotency-Keyヘッダに乗せてRelay APIに送る。サーバー側は同じidを受けたら、過去のレスポンスをそのまま返す。

func send(_ snapshot: OutboxSnapshot) async throws {
    var request = URLRequest(url: relayURL)
    request.httpMethod = "POST"
    request.setValue(snapshot.id.uuidString, forHTTPHeaderField: "Idempotency-Key")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.timeoutInterval = 15
    let payload = RelayPayload(body: snapshot.body, locale: snapshot.locale)
    request.httpBody = try JSONEncoder().encode(payload)
    let (_, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
        throw RelayError.badStatus
    }
}

これだけで、ネットワーク不安定で同じメモが2通届く事故がほぼ消えた。Relay API側はRedisで24時間idを保持するだけの簡素な実装で済む。サーバー側の重複検知はOutbox側に責任を持たせる、というのが分担として正しい。クライアントだけで二重送信を防ごうとすると、Local State機の管理が際限なく増える。

なぜactorで包むのか

Outboxの処理は本質的に「シリアル」だ。同じOutboxItemを2つのworkerが同時に拾うと、二重送信が起きる。だから「拾う→送る→更新する」を直列化する必要がある。

ロックでも書けるが、Swift Concurrencyのactorは「アクター内のメソッドは同時に1つしか実行されない」という性質をコンパイラレベルで保証してくれる。デッドロックの原因になりやすいNSLockやDispatchQueue.syncを排除できる。

actor OutboxRunner {
    private let store: OutboxStore
    private let api: RelayAPI
    private var isRunning = false

    init(store: OutboxStore, api: RelayAPI) {
        self.store = store
        self.api = api
    }

    func tick() async {
        guard !isRunning else { return }
        isRunning = true
        defer { isRunning = false }

        guard let snapshots = try? await store.pending() else { return }
        for snap in snapshots {
            do {
                try await api.send(snap)
                try? await store.markSent(id: snap.id)
            } catch {
                try? await store.markFailed(id: snap.id, error: error)
            }
        }
    }
}

isRunningフラグで「tickの二重実行」も防いでいる。actor内では並行アクセスがないので、フラグの読み書きにロックが要らない。これはactorの旨味そのものだ。

しかもtick()は外から非同期で何度呼ばれても良い。たとえばアプリ起動時、フォアグラウンド復帰時、ネットワーク復帰のNWPathMonitor通知時、Timerからの定期実行時。全部から呼んでも、最初の1つしか実体は動かない。

3回書き直した変遷:何が壊れたから今の形になったのか

最初から今の設計になったわけではない。ここに変遷を残しておくのは、同じ落とし穴を踏む人を1人でも減らすためだ。

バージョン1(2025年12月):DispatchQueueの直列キュー

最初の実装はSwift Concurrency以前の作法で書いていた。DispatchQueue(label: "outbox", qos: .utility)の直列キューに送信タスクを積み、Core DataはmainContextを直接触っていた。

// 古いバージョン1の抜粋(今は使っていない)
let queue = DispatchQueue(label: "outbox", qos: .utility)
queue.async {
    let context = persistentContainer.viewContext
    context.performAndWait {
        // 失敗パターン:viewContextをbackgroundで触っている
    }
}

これは2週間で破綻した。理由は2つある。1つはperformAndWaitの中でURLSessionを待つと、ブロックが10秒スタックすることがあった。URLSession.shared.dataTaskのcompletion handlerはmainで返ってくるので、待ち合わせがすぐ破綻する。2つ目はviewContextを別スレッドから触る違反でクラッシュレポートが週に3〜5件届いた。

教訓は「viewContextは絶対にbackgroundに渡さない」「DispatchQueueとURLSessionの混合は最終的に詰む」だった。

バージョン2(2026年1月):actorだけ入れて満足してしまった

次に書いたのがOutboxStore actorだ。これは安全になった。クラッシュは消えた。しかし速度を失った。

問題は「全てのCore Data操作をactorに集約した結果、UIの描画フローまでactor境界を越えるようになった」ことだった。送信タップの瞬間にawait store.enqueue(...)がメインスレッドからactorへのhopを発生させる。1回のhopは1msだが、actorが他の長時間処理(バックグラウンドのsend)でbusyだと、UIスレッドが100ms近く待たされる。

// バージョン2でやってしまった失敗
@MainActor
func didTapSend(_ text: String) async {
    await outboxStore.enqueue(body: text, locale: locale)
    // ↑ ここがOutboxRunner.tick()と直列になる
    dismissEditor()
}

enqueuependingはDB操作だけなので別々のキューに分けるべきだった。OutboxStoreactorを「書き込み専用」と「読み出し+状態更新専用」の2つに割ることで解決した。Core Dataのcontextも2つに分け、SQLite側で読み書きを並列化する。

バージョン3(2026年4月、今の形):write-actorとwork-actorに分割

最終形はOutboxStoreを2つのactorに分けた。OutboxWriterはUIから呼ばれるenqueue専用。OutboxRunnerが裏で読み出し・送信・状態更新を担当する。Core Data contextは2つだが、automaticallyMergesChangesFromParentを有効にして互いに変更を反映させる。

actor OutboxWriter {
    private let context: NSManagedObjectContext
    init(container: NSPersistentContainer) {
        self.context = container.newBackgroundContext()
        self.context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
    func enqueue(body: String, locale: String) async throws -> UUID {
        let id = UUID()
        try await context.perform {
            let item = OutboxItem(context: self.context)
            item.id = id
            item.body = body
            item.locale = locale
            item.state = OutboxState.pending.rawValue
            item.nextAttemptAt = Date()
            item.createdAt = Date()
            try self.context.save()
        }
        return id
    }
}

これでUIのdidTapSendは最短で10ms、最長でも20msに収まるようになった。Runner側がどれだけ遅くてもUIは止まらない。バージョン2と3の体感差は明確で、テスター3人が「Day1→Day2より、Day2→Day3の方が違いが分かる」と言ってきた。

Outboxは過剰設計ではないのか

ここまで読んで「個人開発のメモアプリでOutbox?」と思うエンジニアは多い。実際、Hacker NewsやRedditのr/iOSProgrammingで同じ議論を何度か見かけた。

カウンターの主張はこうだ。「送信失敗時にトーストを出して、ユーザーに再送ボタンを押させればいい。Outboxは複雑性を持ち込みすぎる」。確かに本数で見るとOutbox関連のコードは追加で約500行ある。テストは別途200行。これを「メモアプリ1本のために書く価値があるか」と問われたら、答えは設計思想に依存する。

これは半分正しい。シンプルなアプリならその通りだ。しかしCaptio式シンプルメモは「送ったら忘れる」体験を売っている。送信失敗トーストを出した瞬間、その世界観は崩れる。「ユーザーに気づかせない」ことが商品価値の中核にあるなら、Outboxは装飾ではなくコア仕様だ。

逆に、ユーザーが送信状態を能動的に確認したいアプリ(メッセンジャー、メール、Slackなど)ではOutboxよりも明示的なステータス表示の方が正解だろう。設計判断は「ユーザーに何を見せ、何を隠したいか」で決まる。

だからこそ、Outboxを使うか否かは技術選定ではなく、プロダクト判断だと考えている。Captioで「captio 代替 ios」を探しているような人は、たぶん同じ判断を期待しているはずだ。

Outbox実装チェックリスト

iOSでOutboxを導入するときに、自分が踏んだ落とし穴をリスト化しておく。3回書き直した結果として、確信のある項目だけ並べる。

  • Core Data contextはnewBackgroundContext()を1つだけ作ってactor内に保持する。viewContextは触らない
  • pendingを取得するクエリにfetchLimitを必ず付ける。10万件溜まったときに死ぬ
  • attemptCountはInt64で持つ。Int32だと2^31で溢れる(実際は起きないが、リミットなしの再送ループで一度フリーズした)
  • lastErrorは512バイトで切る。NSErrorのuserInfoを雑に文字列化するとCore Dataのsqliteが肥大化する
  • ジッタは最低でも20%入れる。指数バックオフだけだと圏外復帰でthundering herdが起きる
  • 再送上限は明示する。Captio式シンプルメモは10回失敗でfailed状態に遷移させ、ユーザーが手動で確認できるリストに出す
  • Idempotency-Keyはサーバー側で必ず受ける。クライアントだけで送っても効果なし
  • アプリ起動時、フォアグラウンド復帰時、NWPathMonitor.satisfied通知時の3点でtick()を呼ぶ
  • Background Refreshには載せない。電池消費とユーザー体感の比でほぼ割に合わない

今日の学び:UIの速さは"確実性"の上にだけ立つ

最初は「Outboxは分散システムの大袈裟な道具」だと思っていた。違った。Outboxは「UIを速くするための仕掛け」だ。永続化と送信を分離すれば、UIは永続化だけ待てば良くなる。残りはバックグラウンドが面倒を見る。

Captio式シンプルメモのText-to-Send 150msとTime-to-Text 500msという数字も、Outboxの上にだけ成立する。送信完了を待つ設計だと、地下鉄に乗った瞬間にUIが止まる。送信を「永続化したら成功」と再定義することで、初めて150msが実現する。

Day1で書いた「速さは設計の話」というのは、こういうことだった。技術選定が体験の質を決め、体験の質がユーザーの信頼を決める。だからOutboxは"裏方"だが、UIの主役の支え棒として存在している。

次回(Day4)予告:起動0.3秒を支える実装

次は起動速度の解剖に入る。

  • UISceneSessionとstateRestorationActivityの使い分け:cold startを0.3秒に収めるための初期化順序
  • キーボード予熱:UITextViewを画面外に1個置いておくテクニックの効果検証と落とし穴
  • UIKitを選んだ判断は本当に正しかったのか:SwiftUIで同じ起動速度が出せるかの実験ログ

技術寄りの記事が続くので、もし「個人開発 メモアプリ 起動速度」「uikit swift 起動 高速化」あたりで困っているエンジニアの参考になれば嬉しい。

コメントで教えてください

iOSアプリでOutboxパターンを使った経験のある人、もしくは「Outboxは要らない、こう書けば十分」という反対意見、両方とも聞きたい。Core Dataの代わりにSQLiteを直接叩いた話や、GRDB・SQLite.swiftを使った話もあれば是非。@ModelActorのSwiftDataで書き直した話も興味がある。


Captio式シンプルメモ
外部ライブラリ依存ゼロ。Swift + Apple純正フレームワークだけで作った、起動0.3秒のメモアプリ。
App Store:https://apps.apple.com/jp/app/captio%E5%BC%8F%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%83%A1%E3%83%A2/id6749649498

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?