В асинхронному JavaScript вбудовані примітиви конкурентності (Promise.all, Promise.race, Promise.any) не мають властивості структурної цілісності:
Promise.all([p1, p2, p3]) запит p2 падає з помилкою через 10 мс, Promise.all негайно відхиляється (reject). Проте задачі p1 та p3 продовжують виконуватися у фоні до свого завершення, марнуючи сокети, пам'ять та процесорний час.Promise.race: Коли один із промісів вирішується першим, інші проміси не перериваються. Їхні обробники продовжують виконуватися в Event Loop браузера або Node.js.AbortController, що в глибоких деревах викликів призводить до помилок забутих слухачів.Екосистема Effect-TS вирішує задачу конкурентності через концепцію структурної конкурентності (Structured Concurrency).
Фундаментом цієї моделі слугують:
У структурній конкурентності Effect обчислення розгалужуються у вигляді суворого дерева віртуальних потоків (Fiber Tree):
+-------------------------------------------------------------------------+
| БАТЬКІВСЬКЕ ВОЛОКНО (PARENT FIBER) |
| Effect.all([task1, task2, task3], { concurrency: "unbounded" }) |
+-------------------------------------------------------------------------+
| | |
| fork | fork | fork
v v v
+---------------+ +---------------+ +---------------+
| Дочірнє #1 | | Дочірнє #2 | | Дочірнє #3 |
| (Мережевий I/O| | (ФАТАЛЬНИЙ | | (Важкий CPU |
| | | ЗБІЙ: 15ms) | | розрахунок) |
+---------------+ +---------------+ +---------------+
| | |
| АВТО-ПЕРЕРИВАННЯ | ТРИГЕР ЗБОЮ | АВТО-ПЕРЕРИВАННЯ
v (Interrupt) v v (Interrupt)
+-----------------------------------------------------------------------------+
| ПОШИРЕННЯ СИГНАЛУ ПЕРЕРИВАННЯ: |
| 1. Збій Волокна #2 негайно сповіщає батьківський потік. |
| 2. Батько посилає Fiber.interrupt волокнам #1 та #3. |
| 3. Волокна #1 та #3 негайно зупиняються, виконуючи свої фіналізатори Scope |
| (закривають сокети, звільняють буфери). Жодних фонових витоків! |
+-----------------------------------------------------------------------------+Schedule.exponential.concurrency: 4), де збій одного критичного сервісу негайно скасовує решту запитів.Effect.race: перший успішний регіон перемагає, а з'єднання з другим негайно переривається.Effect.fork, join та interrupt#Будь-який ефект може бути запущений асинхронно в окремому волокні через метод Effect.fork:
import { Effect, Fiber, Duration } from 'effect';
const backgroundTask = Effect.gen(function* () {
yield* Effect.logInfo('Фонове волокно запущено...');
// Імітація тривалої операції
yield* Effect.sleep(Duration.seconds(10));
yield* Effect.logInfo('Фонове волокно завершено.');
return 100;
});
export const mainProgram = Effect.gen(function* () {
// 1. Відгалуження задачі в окреме волокно (Green Thread)
const fiber = yield* Effect.fork(backgroundTask);
// 2. Основний потік працює паралельно
yield* Effect.sleep(Duration.seconds(1));
yield* Effect.logInfo('Основний потік вирішує перервати фонову задачу!');
// 3. Безпечне примусове переривання волокна
yield* Fiber.interrupt(fiber);
yield* Effect.logInfo('Фонове волокно успішно зупинено без витоків.');
});Schedule#Модуль Schedule надає математичний апарат для моделювання будь-яких інтервалів повторів:
import { Effect, Schedule, Duration, Data, pipe } from 'effect';
export class TransientNetworkError extends Data.TaggedError('TransientNetworkError')<{
readonly statusCode: number;
}> {}
export class FatalAuthError extends Data.TaggedError('FatalAuthError')<{
readonly reason: string;
}> {}
// Нестабільний запит до віддаленого шлюзу
let attempt = 0;
const unstableRequest = Effect.gen(function* () {
attempt++;
yield* Effect.logInfo(`Спроба мережевого запиту #${attempt}`);
if (attempt < 3) {
return yield* Effect.fail(new TransientNetworkError({ statusCode: 503 }));
}
return { data: 'Успішна відповідь шлюзу' };
});
// Конфігурація розкладу повторів:
// Початкова пауза 100мс -> експоненційне зростання -> максимум 5 спроб -> додавання джиттеру
const retrySchedule = pipe(
Schedule.exponential(Duration.millis(100)),
Schedule.jittered, // Захист від Thundering Herd: рандомізація дельти часу (+- 20%)
Schedule.compose(Schedule.recurs(5))
);
// Застосування політики повторів ТІЛЬКИ для тимчасових помилок мережі
export const resilientCall = unstableRequest.pipe(
Effect.retry({
schedule: retrySchedule,
while: (error) => error._tag === 'TransientNetworkError',
})
);Effect.race)#В Effect комбінатор timeout автоматично перериває активне волокно при закінченні ліміту часу:
import { Effect, Duration } from 'effect';
const fastPrimaryService = Effect.sleep(Duration.millis(200)).pipe(
Effect.as('PRIMARY_REGION_DATA')
);
const backupService = Effect.sleep(Duration.millis(500)).pipe(
Effect.as('BACKUP_REGION_DATA')
);
// 1. Перегони обчислень: швидке обчислення перемагає, повільне гарантовано переривається
export const hedgedRequest = Effect.race(fastPrimaryService, backupService);
// 2. Жорсткий таймаут виконання
export const timedOperation = hedgedRequest.pipe(
// Якщо операція триває довше 350мс, вона переривається з помилкою NoSuchElementException
Effect.timeout(Duration.millis(350)),
// Обробка таймауту
Effect.orElse(() => Effect.succeed('FALLBACK_STALE_CACHE'))
);При необхідності обробити масив із 1000 елементів без переповнення сокетів бази даних використовується опція concurrency:
import { Effect, Duration } from 'effect';
const itemIds = Array.from({ length: 1000 }, (_, i) => `item_${i}`);
const processItem = (id: string) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Обробка ${id}`);
yield* Effect.sleep(Duration.millis(50));
return `result_${id}`;
});
// Обробка 1000 елементів пачками суворо по 5 паралельних потоків
export const throttledBatch = Effect.all(
itemIds.map((id) => processItem(id)),
{ concurrency: 5 } // Ліміт віртуальних волокон
);Запуск віртуального потоку з реєстрацією таймера таймауту в контексті рантайму.
Сервер повернув 503. Schedule обчислює експоненційну паузу з випадковим джиттером.
Волокно засинає на обчислену дельту та виконує повторний вхід у ту саму операцію.
Якщо відповідь отримана до закінчення таймауту — повернення результату. Інакше — примусове закриття сокета.
Нижче наведено production-модуль відмовостійкого виклику зовнішніх сервісів з автоматичними повторами, таймаутом та ізоляцією збоїв:
import { Effect, Schedule, Duration, Data, pipe } from 'effect';
export class TimeoutExceededError extends Data.TaggedError('TimeoutExceededError')<{
readonly serviceName: string;
readonly timeoutMs: number;
}> {}
export class GatewayUnavailableError extends Data.TaggedError('GatewayUnavailableError')<{
readonly serviceName: string;
readonly cause: unknown;
}> {}
export interface GatewayConfig {
readonly serviceName: string;
readonly timeoutMs: number;
readonly maxRetries: number;
readonly initialBackoffMs: number;
}
export function executeWithResilience<A, E>(
action: Effect.Effect<A, E, never>,
config: GatewayConfig
): Effect.Effect<A, E | TimeoutExceededError | GatewayUnavailableError, never> {
// 1. Політика повторних спроб з експоненційним джиттером
const retryPolicy = pipe(
Schedule.exponential(Duration.millis(config.initialBackoffMs)),
Schedule.jittered,
Schedule.compose(Schedule.recurs(config.maxRetries))
);
return pipe(
action,
// Застосовуємо політику повторів до тимчасових збоїв
Effect.retry(retryPolicy),
// Обмежуємо сумарний час виконання таймаутом
Effect.timeout(Duration.millis(config.timeoutMs)),
// Перетворюємо таймаут у доменну типізовану помилку
Effect.mapError((err) => {
if (err instanceof Error && err.name === 'TimeoutException') {
return new TimeoutExceededError({
serviceName: config.serviceName,
timeoutMs: config.timeoutMs,
});
}
return err;
}),
// Перехоплюємо фатальні дефекти та загортаємо в контрольований тип
Effect.catchAllDefect((defect) =>
Effect.fail(
new GatewayUnavailableError({
serviceName: config.serviceName,
cause: defect,
})
)
)
);
}Змішування Promise.all з Effect руйнує структурну конкурентність. Якщо один із промісів усередині Promise.all впаде, решта промісів продовжать безконтрольно висіти в пам'яті Node.js.
Рішення: завжди використовувати Effect.all(effects, { concurrency: ... }).
Сліпе застосування Effect.retry(Schedule.recurs(5)) до запитів автентифікації (401 Unauthorized) або валідації даних (400 Bad Request) лише перевантажує сервер безглуздим трафіком.
Рішення: завжди обмежувати умову повторів предикатом while: (err) => isTransient(err).
Волокна Effect є кооперативними. Якщо запустити у волокні важкий синхронний цикл обчислень (while(true) {}), він заблокує весь потік JavaScript, не даючи іншим волокнам навіть обробити сигнал переривання.
Рішення: періодично віддавати квант часу планувальнику через виклик yield* Effect.yieldNow().
Виділити помилки мережі (502, 503, 504, ECONNRESET), відокремивши їх від бізнес-збоїв (400, 401, 404).
Сконфігурувати Schedule.exponential з джиттером та максимальним числом спроб (3-5).
Обернути операцію в Effect.timeout для запобігання вічним підвисанням сокетів.
При паралельних викликах задати суворий ліміт { concurrency: N } виходячи з ємності пулу.
Перевірити сценарії обриву мережі та переконатися, що скасовані волокна закривають ресурси.