1. Стратегія «все або нічого» шкодить пакетним операціям
Коли ми виконуємо пакетну операцію, наприклад «завантаж 30 книг за id», у новачка природно виникає бажання: «Якщо щось пішло не так — нехай усе впаде, зате я хоча б зрозумію, що сталася помилка». Це логічно, але в реальному житті такий підхід занадто дорогий: мережа може смикнутися, один id може виявитися пошкодженим, одна відповідь може прийти з 404, і через один збій ви втратите 29 успішних результатів. У цей момент користувач думає не про вашу архітектуру, а про те, що застосунок поводиться нервово.
Є дві політики, і обидві нормальні — просто розв’язують різні завдання.
| Політика | Ідея | Коли доречна | Що бачить користувач |
|---|---|---|---|
| Fail-fast | Перша помилка перериває всю операцію | Коли «половинчастий» результат беззмістовний, наприклад у транзакції | Одна помилка, але нуль результатів |
| Partial success | Збираємо все, що вдалося, а помилки враховуємо окремо | Коли кожен елемент незалежний: завантажити N сутностей, обробити N файлів | І результат, і список проблем |
Сьогодні нам потрібен partial success: ми хочемо витягнути максимум користі з пакетної операції й водночас не втратити інформацію про помилки.
2. Ідея partial success: помилка як дані і Result
Якщо ви колись писали try? і раділи, що «код не падає», то ви вже майже зробили partial success — тільки без найціннішої частини, без розуміння, чому не вийшло. Partial success зазвичай вимагає зберегти помилку як частину звіту. Для цього зручно уявити помилку не як «особливий маршрут керування» через throw, а як звичайне значення, яке можна покласти в масив.
Чому Result для цього ідеальний
У Swift є Result<Success, Failure>, який зберігає або .success(value), або .failure(error) — без проміжних станів. Саме так Result і задумано: або успіх, або помилка, в одному типі.
Невеликий приклад, щоб «помацати» ідею руками:
import Foundation
enum ParseError: Error { case notANumber }
func parseInt(_ s: String) -> Result<Int, Error> {
guard let x = Int(s) else { return .failure(ParseError.notANumber) }
return .success(x)
}
let r = parseInt("42")
print(r) // success(42)
Ключова думка: Result можна складати в колекції, передавати між функціями, повертати із задач, сортувати, рахувати — і все це залишається звичайними даними, а не «стрибками» між throw/catch.
Чому TaskGroup без Result тягне нас до fail-fast
Якщо ви використовуєте withThrowingTaskGroup, то всередині задач можна використовувати throw, а зовні ви змушені читати результати через try await group.next(). Помилка дочірньої задачі проявляється саме в момент отримання результату: відповідний next() кине помилку, і ви або обробите її, або вона вийде назовні.
Ця поведінка дуже зручна, коли вам потрібен fail-fast, але вона заважає partial success: ви хочете продовжити збирати результати навіть тоді, коли частина задач упала.
Міні-демо, не для копіювання в продакшн, а щоб відчути механіку:
import Foundation
enum DemoError: Error { case boom(Int) }
func risky(_ x: Int) async throws -> Int {
if x == 3 { throw DemoError.boom(x) }
return x * 10
}
// Ідея: перша помилка може "зірвати" збір, якщо ми не проєктуємо partial success.
У TaskGroup можна вручну ігнорувати окремі помилки, але для навчального проєкту й для читабельності нам вигідніше обрати простий і стабільний шаблон: задача ніколи не кидає назовні, вона повертає Result.
3. Шаблон partial success: дочірня задача повертає (id, Result)
У цьому місці часто відбувається психологічний перелом: «навіщо мені Result, якщо в мене вже є throws?». Відповідь проста: throws чудово працює, коли у вас один результат, а тут їх багато, і ми не хочемо, щоб один throw валив усю пачку. Тому ми робимо так: кожна дочірня задача ловить свою помилку і повертає її як .failure.
Найпростіша «іграшкова» версія:
import Foundation
enum LoadError: Error { case failed(Int) }
func load(_ id: Int) async throws -> String {
if id.isMultiple(of: 4) { throw LoadError.failed(id) }
return "item-\(id)"
}
func loadMany(_ ids: [Int]) async -> [(Int, Result<String, Error>)] {
await withTaskGroup(of: (Int, Result<String, Error>).self) { group in
for id in ids {
group.addTask {
do { return (id, .success(try await load(id))) }
catch { return (id, .failure(error)) }
}
}
var out: [(Int, Result<String, Error>)] = []
while let item = await group.next() { out.append(item) }
return out
}
}
Зверніть увагу: withTaskGroup тут не throwing, тому збір результатів не може зірватися через try await group.next(). Ми гарантуємо, що кожна задача завжди повертає значення: або успіх, або помилку.
4. Вбудовуємо в LibraryCLI: batch-fetch і прогрес
Уявімо ситуацію з нашого проєкту: у нас є команда або підсценарій сервісу, який отримує список BookID і має завантажити дані за кожним id з мережі. Мережевий шар уже вміє повертати помилки, наприклад наш NetworkError із попередніх лекцій, а репозиторій ми оновлюємо послідовно за правилами single-writer.
Зараз ми реалізуємо саме серце partial success: сервіс поверне зведений результат, у якому будуть і успішні книги, і список помилок за конкретними id.
Щоб не перетворювати код на роман із 900 сторінок, почнемо з маленьких типів — це дуже підвищує читабельність.
Спочатку заведемо контейнер результату пакетної операції:
import Foundation
struct BatchOutcome<Success> {
var values: [Success] = []
var failures: [(id: String, error: Error)] = []
}
Так, id тут String, тому що нам важливо виводити його користувачу й логеру; у реальному коді ви можете зберігати BookID, але для звіту все одно перетворюватимете його на рядок.
Тепер задамо «одиницю результату» однієї задачі: вона має повернути і id, і Result:
import Foundation
typealias BookFetchItem = (id: String, result: Result<Book, Error>)
(Тип Book у нас уже є в домені; тут важливий не вміст книги, а те, що це «успіх».)
Тепер найважливіша частина: функція, яка робить batch-fetch і збирає partial success.
import Foundation
func fetchMany(ids: [String], api: ApiClient) async -> BatchOutcome<Book> {
await withTaskGroup(of: BookFetchItem.self, returning: BatchOutcome<Book>.self) { group in
for id in ids {
group.addTask {
do {
let book = try await api.fetchBook(id: id)
return (id, .success(book))
} catch {
return (id, .failure(error))
}
}
}
var out = BatchOutcome<Book>()
while let item = await group.next() {
switch item.result {
case .success(let book): out.values.append(book)
case .failure(let e): out.failures.append((id: item.id, error: e))
}
}
return out
}
}
Тут одразу кілька речей — як за підручником:
- Ми додали стільки задач, скільки прийшло ids. Саме для цього TaskGroup нам і потрібен: кількість задач динамічна.
- Ми не використовуємо withThrowingTaskGroup, тому що нам не потрібен fail-fast. За специфікацією structured concurrency помилка в throwing-групі проявляється через try await next() і може автоматично припинити збір, якщо ми не спроєктуємо це окремо.
- Ми збираємо результати тільки в батьківському циклі next(). Це стиль, який робить код передбачуваним: у дочірніх задачах немає мутацій спільних масивів, отже менше шансів зловити гонки й дивні ефекти, а ще так простіше тестувати.
Щоб показати прогрес, що зазвичай приємно в CLI, можна додати простий лічильник — у батьківському коді:
import Foundation
func fetchManyWithProgress(ids: [String], api: ApiClient) async -> BatchOutcome<Book> {
await withTaskGroup(of: BookFetchItem.self, returning: BatchOutcome<Book>.self) { group in
for id in ids {
group.addTask {
do { return (id, .success(try await api.fetchBook(id: id))) }
catch { return (id, .failure(error)) }
}
}
var done = 0
var out = BatchOutcome<Book>()
while let item = await group.next() {
done += 1
print("Завантажено: \(done)/\(ids.count)") // Завантажено: 1/5 ...
if case .success(let book) = item.result { out.values.append(book) }
if case .failure(let e) = item.result { out.failures.append((item.id, e)) }
}
return out
}
}
Так, вивід може надходити в різному порядку — тому що задачі завершуються в порядку завершення, і це нормально: TaskGroup від самого початку так працює, а next() повертає «наступне готове» значення, а не «наступне додане».
5. Звіт і корисні нюанси partial success
Коли ви отримали BatchOutcome, у вас з’являється приємна свобода: ви можете окремо вирішити, що показувати користувачеві, а що відправити в лог. Важливо не змішувати ці рівні: користувачеві потрібні короткі й зрозумілі повідомлення, а розробникові, тобто вам через місяць, — деталі помилок.
Як перетворити BatchOutcome на зрозумілий звіт для CLI
Найпростіший формат звіту може виглядати так:
import Foundation
func renderReport(_ outcome: BatchOutcome<Book>) -> String {
"Успішно: \(outcome.values.count), помилок: \(outcome.failures.count)"
}
print(renderReport(outcome)) // Успішно: 8, помилок: 2
А якщо потрібно вивести деталі помилок, наприклад лише у verbose-режимі, можна зробити окрему функцію форматування:
import Foundation
func renderFailures(_ failures: [(id: String, error: Error)]) -> String {
failures.map { "id=\($0.id): \($0.error)" }.joined(separator: "\n")
}
Сенс у тому, що TaskGroup і partial success дають вам дані, а оформлення — це вже відповідальність CLI-шару, який вирішує, що показувати.
Нюанси: порядок, контекст і побічні ефекти
У partial success є три типові «міни», на які наступають навіть розумні люди, особливо коли дедлайн дихає в потилицю і кава вже не допомагає.
Перша міна — очікування, що початковий порядок збіжиться з порядком завершення. TaskGroup віддає результати в міру завершення, і це нормально: next() повертає одне з готових значень. Якщо порядок важливий, ви повертаєте із задачі не лише id, а й index і розкладаєте результат у масив за індексом, як ми вже обговорювали в лекції про completion order. У сьогоднішній лекції порядок для нас не критичний: ми або додаємо книги в репозиторій, або просто збираємо список успішних — там порядок зазвичай не є інваріантом.
Друга міна — «помилка без адреси». Якщо ви повертаєте із задачі просто Result<Book, Error>, то в разі збою у вас залишається тільки Error, а питання «який id упав?» перетворюється на квест. Тому ми повертаємо (id, Result) — контекст зберігається поруч із помилкою, і звіт можна робити нормальною людською мовою.
Третя міна — побічні ефекти всередині дочірніх задач. Дуже хочеться зробити так: «усередині addTask завантажив книгу — одразу записав у репозиторій». Але це погана звичка: спільний ресурс стає точкою гонок і непередбачуваних станів. У нашій архітектурі це порушує дисципліну single-writer: ми паралельно отримуємо дані, але зміни застосовуємо послідовно.
Зручно тримати це в голові як двофазну схему:
flowchart TD
A["ідентифікатори: [BookID]"] --> B[TaskGroup: паралельне отримання]
B --> C[BatchOutcome: успіхи + помилки]
C --> D[Послідовно: оновити репозиторій і зберегти файл]
І так, це звучить трохи більш бюрократично, ніж «давай просто запишемо одразу», але зате після цього ви значно рідше чуєте від користувача: «Воно то працює, то ні». Це найстрашніший баг, бо його важко відтворити і неможливо вмовити.
6. Типові помилки
Після знайомства з partial success багато хто починає радісно загортати все в Result, а потім дивується: «чому звіт порожній?», «чому помилок немає, хоча явно щось зламалося?», «чому масив книг то 10, то 9?». Найчастіше це не містика, а кілька повторюваних помилок мислення.
Помилка №1: використовувати try? і втрачати помилку.
try? перетворює помилку на nil, і для partial success це майже завжди занадто агресивно: ви хочете не просто «не впасти», а зібрати звіт. Якщо вже ви обираєте partial success, то помилка — частина результату, а отже її потрібно зберігати як .failure(error), а не викидати в кошик.
Помилка №2: повертати із задачі тільки Result, без контексту (id/index).
Технічно код працюватиме, але практично ви отримуєте «анонімні помилки». Користувач побачить «decode failed», а ви не знатимете, який саме id дав збій. У пакетних операціях контекст — це половина цінності звіту, тому повертайте (id, Result).
Помилка №3: мутувати спільний масив/словник усередині group.addTask.
Навіть якщо «ніби працює», це неприємний шлях: конкурентні мутації, перемішані стани, баги, які важко відтворити. Правильний стиль — збирати результати через group.next() в одному місці, у батьківському коді, бо саме він контролює потік завершення задач.
Помилка №4: очікувати, що TaskGroup збереже порядок вхідних даних.
За замовчуванням результати приходять у completion order, тому що next() повертає «наступну завершену задачу». Якщо порядок важливий, його потрібно відновлювати явно через (index, value) і розкладку за індексом, а не сподіватися на удачу.
Помилка №5: змішати «збір результатів» і «застосування ефектів» в одну фазу без дисципліни.
Інколи пишуть так: «щойно прийшов успіх — одразу записав файл», «щойно прийшла помилка — одразу друкую користувачу». У підсумку вивід стає хаотичним, запис може бути частковим, а тести — нестабільними. Краще так: спочатку зібрати BatchOutcome, а вже потім окремо вирішити, як його застосувати і як його показати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ