1. Стратегия «всё или ничего» вредно в batch‑операциях
Когда мы делаем batch‑операцию (например, «скачай 30 книг по id»), у новичка возникает естественное желание: «если что-то пошло не так — пусть всё упадёт, я хотя бы пойму, что ошибка». Это логично, но в реальной жизни слишком дорого: сеть может дёрнуться, один id может быть битым, один ответ может прийти с 404, и из‑за одного сбоя вы теряете 29 успешных результатов. Пользователь в этот момент думает не про вашу архитектуру, а про то, что «приложение нервное».
Есть две политики, и они обе «нормальные», просто решают разные задачи.
| Политика | Идея | Когда уместно | Что получает пользователь |
|---|---|---|---|
| Fail‑fast | Первая ошибка прерывает всю операцию | Когда «половинчатый» результат бессмысленен (например, транзакция) | Одна ошибка, но ноль результата |
| Partial success | Собираем всё, что получилось, а ошибки учитываем отдельно | Когда каждый элемент независим (скачать N сущностей, обработать N файлов) | И результат, и список проблем |
Сегодня нам нужен partial success: мы хотим «вытянуть максимум пользы» из batch‑операции и при этом не потерять информацию об ошибках.
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 страниц, начнём с маленьких типов — это сильно повышает читаемость.
Сначала заведём контейнер результата batch‑операции:
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
}
}
Да, вывод может прийти в разном порядке — потому что задачи завершаются в completion order, и это нормально: 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‑слоя (который умеет решить, что показывать).
Нюансы: порядок, контекст и side effects
В partial success есть три типичные «мины», на которые наступают даже умные люди (особенно когда дедлайн дышит в затылок и кофе уже не помогает).
Первая мина — ожидание исходного порядка. TaskGroup отдаёт результаты по мере завершения, и это нормально: next() возвращает одно из готовых значений. Если порядок важен, вы возвращаете из задачи не только id, но и index и раскладываете результат в массив по индексу (это мы уже обсуждали в лекции про completion order). В сегодняшней лекции нам порядок не критичен: мы либо добавляем книги в репозиторий, либо просто собираем список успешных — там порядок обычно не является инвариантом.
Вторая мина — «ошибка без адреса». Если вы возвращаете из задачи просто Result<Book, Error>, то при падении у вас остаётся только Error, а вопрос «какой id упал?» превращается в квест. Поэтому мы возвращаем (id, Result) — контекст хранится рядом с ошибкой, и отчёт можно делать нормальным человеческим языком.
Третья мина — side effects внутри дочерних задач. Очень хочется сделать так: «внутри addTask скачал книгу — сразу записал в репозиторий». Но это плохая привычка: общий ресурс становится точкой гонок и непредсказуемых состояний. В нашей архитектуре это нарушает single-writer дисциплину: конкурентно получаем данные, но применяем изменения последовательно.
Удобно держать это в голове как двухфазную схему:
flowchart TD
A["ids: [BookID]"] --> B[TaskGroup: параллельно fetch]
B --> C[BatchOutcome: successes + failures]
C --> D[Последовательно: обновить repo и сохранить файл]
И да, это звучит чуть более «бюрократично», чем «давай просто запишем сразу», но зато после этого вы гораздо реже слышите от пользователя: «оно иногда работает, иногда нет» — самый страшный баг, потому что его сложно воспроизвести и невозможно уговорить.
6. Типичные ошибки
После знакомства с partial success многие начинают радостно заворачивать всё в Result, а потом удивляются: «почему отчёт пустой?», «почему ошибок нет, хотя явно что-то сломалось?», «почему массив книг то 10, то 9?». Чаще всего это не мистика, а несколько повторяющихся ошибок мышления.
Ошибка №1: использовать try? и терять ошибку.
try? превращает ошибку в nil, и для partial success это почти всегда слишком агрессивно: вы хотите не просто «не упасть», а собрать отчёт. Если уж вы выбираете partial success, то ошибка — часть результата, а значит её нужно хранить как .failure(error), а не выбрасывать в корзину.
Ошибка №2: возвращать из задачи только Result, без контекста (id/index).
Технически код будет работать, но практически вы получаете «анонимные ошибки». Пользователь увидит «decode failed», а вы не будете знать, какой именно id сломался. В batch‑операциях контекст — это половина ценности отчёта, поэтому возвращайте (id, Result).
Ошибка №3: мутировать общий массив/словарь изнутри group.addTask.
Даже если «вроде работает», это неприятная дорожка: конкурентные мутации, перемешанные состояния, сложно воспроизводимые баги. Правильный стиль — собирать результаты через group.next() в одном месте, в родительском коде, потому что именно он контролирует поток завершений задач.
Ошибка №4: ожидать, что TaskGroup сохранит порядок входных данных.
По умолчанию результаты приходят в completion order, потому что next() возвращает «следующую завершившуюся задачу». Если порядок важен, его нужно восстановить явно через (index, value) и раскладку по индексу, а не надеяться на удачу.
Ошибка №5: смешать «сбор результатов» и «применение эффектов» в одну фазу без дисциплины.
Иногда пишут так: «как только пришёл успех — сразу записал файл», «как только пришла ошибка — сразу печатаю пользователю». В итоге вывод становится хаотичным, запись может быть частичной, а тесты — нестабильными. Лучше: сначала собрать BatchOutcome, потом уже отдельно принять решение, как его применять и как его показывать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ