JavaRush /Курси /Swift SELF /Частковий успіх у TaskGroup...

Частковий успіх у TaskGroup: той самий Result

Swift SELF
Рівень 69 , Лекція 3
Відкрита

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, а вже потім окремо вирішити, як його застосувати і як його показати.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ