JavaRush /Курсы /Swift SELF /Частичный успех в TaskGroup...

Частичный успех в TaskGroup: тот же Result

Swift SELF
69 уровень , 3 лекция
Открыта

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, потом уже отдельно принять решение, как его применять и как его показывать.

1
Задача
Swift SELF, 69 уровень, 3 лекция
Недоступна
Разбор чек‑листа
Разбор чек‑листа
1
Задача
Swift SELF, 69 уровень, 3 лекция
Недоступна
Деление без паники
Деление без паники
1
Задача
Swift SELF, 69 уровень, 3 лекция
Недоступна
Пакетная загрузка
Пакетная загрузка
1
Задача
Swift SELF, 69 уровень, 3 лекция
Недоступна
Валидатор имён
Валидатор имён
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ