AbortError и прерванные транзакции

AbortError в контексте IndexedDB и Dexie.js возникает в момент, когда транзакция или операция прерывается до завершения по причинам, не связанным с логической ошибкой бизнес-уровня. Это особый тип сбоя, который сигнализирует не о некорректных данных, а о том, что выполнение цепочки операций было насильственно остановлено окружением, пользователем или самим браузером.

В отличие от ConstraintError, VersionError или QuotaExceededError, которые описывают нарушения правил базы данных, AbortError относится к категории жизненного цикла транзакции. Он почти всегда означает: транзакция не дошла до этапа commit и была откатана.


Как IndexedDB формирует AbortError

В основе Dexie.js лежит IndexedDB, где каждая операция выполняется в рамках транзакции. Транзакция может завершиться тремя путями:

  • успешный commit;
  • ошибка (reject + rollback);
  • принудительный abort.

AbortError появляется именно в третьем сценарии.

Типичные причины на уровне браузера:

  • закрытие вкладки или переход на другую страницу во время выполнения транзакции;
  • вызов transaction.abort() (напрямую или косвенно через Dexie);
  • превышение времени жизни транзакции при высокой конкуренции за store;
  • конфликт блокировок, приводящий к автоматическому abort;
  • использование AbortController, разрывающего цепочку асинхронных операций.

Важно, что IndexedDB не гарантирует завершение долгих транзакций: она оптимизирована под короткие, атомарные операции.


Модель транзакций Dexie.js

Dexie.js расширяет модель IndexedDB, добавляя удобный API:

db.transaction('rw', db.users, db.orders, async () => {
    const user = await db.users.get(1);
    await db.orders.add({ userId: user.id, total: 100 });
});

Внутри такой транзакции Dexie удерживает контекст до завершения функции. Если внутри возникает AbortError, транзакция помечается как прерванная и все изменения откатываются.

Ключевая особенность: Dexie автоматически связывает промисы с жизненным циклом транзакции. Это означает, что любая асинхронная операция внутри блока может привести к abort, если внешний контекст исчез.


Сценарии возникновения AbortError в Dexie.js

Прерывание пользователем или системой

Наиболее простой сценарий — потеря страницы:

  • пользователь закрыл вкладку;
  • браузер выгрузил tab из памяти;
  • произошёл navigation.

В этом случае IndexedDB уничтожает активные транзакции, возвращая AbortError.


Явный abort транзакции

Dexie позволяет вручную прервать транзакцию:

const tx = db.transaction('rw', db.items, async () => {
    db.items.add({ name: 'A' });
    tx.abort();
});

После вызова abort() любые дальнейшие операции внутри транзакции будут отклонены с AbortError.


Конфликты блокировок

IndexedDB использует блокировочную модель. Если одна транзакция удерживает store, другая может быть поставлена в ожидание. При длительном ожидании браузер иногда завершает транзакцию с abort, чтобы избежать дедлока.

Особенно часто это проявляется при:

  • параллельных bulk-write операциях;
  • частых чтениях/записях в одном store;
  • длинных транзакциях с большим количеством await.

AbortController и внешняя отмена

Dexie поддерживает интеграцию с AbortController:

const controller = new AbortController();

db.transaction('rw', db.logs, async () => {
    await db.logs.bulkAdd(data, { signal: controller.signal });
});

При вызове:

controller.abort();

операции Dexie получают сигнал отмены, и транзакция завершается с AbortError.


Внутреннее поведение Dexie при AbortError

Когда IndexedDB выбрасывает AbortError:

  1. Dexie помечает транзакцию как aborted;
  2. все pending promises внутри transaction context отклоняются;
  3. кэш транзакционного контекста очищается;
  4. подписанные hooks (on('error'), catch) получают событие ошибки.

Особенность Dexie заключается в том, что AbortError часто “распространяется” вверх по цепочке промисов, даже если исходная операция была частично завершена.


Отличие AbortError от других ошибок транзакции

AbortError часто путают с:

  • TransactionInactiveError — когда операция выполняется вне активной транзакции;
  • InvalidStateError — когда объект базы находится в неправильном состоянии;
  • обычными reject-ошибками бизнес-логики.

Ключевое отличие AbortError:

  • транзакция не считается завершённой;
  • никакие изменения не фиксируются;
  • состояние базы возвращается к исходному.

Асинхронные цепочки и скрытые причины abort

Одна из сложных проблем Dexie — асинхронные разрывы внутри транзакции.

db.transaction('rw', db.items, async () => {
    const items = await fetch('/api/items').then(r => r.json());
    await db.items.bulkAdd(items);
});

Если внешний await fetch() занимает слишком много времени, транзакция может выйти за пределы допустимого окна активности IndexedDB. Браузер в этот момент может считать транзакцию “зависшей” и завершить её abort’ом.

Dexie в таких случаях не может восстановить контекст — транзакция уже разрушена.


Поведение при retry после AbortError

AbortError часто является временным. Особенно при:

  • конкурирующих write-транзакциях;
  • нестабильной нагрузке;
  • коротких блокировках store.

Типовой паттерн повторной попытки:

async function safeWrite(data, retries = 3) {
    try {
        return await db.transaction('rw', db.items, async () => {
            await db.items.add(data);
        });
    } catch (e) {
        if (e.name === 'AbortError' && retries > 0) {
            return safeWrite(data, retries - 1);
        }
        throw e;
    }
}

Однако retry требует осторожности: повтор транзакции должен быть идемпотентным, иначе возможны дубли записей.


Прерывание длинных bulk-операций

Bulk-операции особенно чувствительны к abort:

await db.items.bulkPut(largeArray);

При:

  • переполнении памяти;
  • длительной блокировке event loop;
  • внешнем abort signal;

операция может завершиться AbortError без частичного commit.

Dexie гарантирует атомарность транзакции: либо все элементы записаны, либо ни один.


Работа с hooks и AbortError

Hooks Dexie (creating, updating, deleting) могут косвенно вызывать abort, если внутри них происходит асинхронная логика, нарушающая жизненный цикл транзакции.

db.items.hook('creating', async (primKey, obj, tx) => {
    await fetch('/validate'); // потенциальный риск
});

Если hook задерживает транзакцию, браузер может завершить её AbortError.


Паттерны устойчивости к AbortError

Разделение транзакций

Критично избегать длинных RW-транзакций:

  • чтение отдельно;
  • запись отдельно;
  • внешние API вызовы вне транзакции.

Минимизация времени транзакции

Транзакция должна содержать только IndexedDB операции:

  • никаких сетевых запросов;
  • никаких тяжёлых вычислений;
  • минимум await внутри блока.

Изоляция внешних сигналов

Использование AbortController должно быть контролируемым:

  • сигнал отмены только для user-driven cancellation;
  • избегать автоматического abort без причины.

Контроль конкурентных записей

При массовых операциях важно учитывать конкуренцию:

  • сериализация write-транзакций;
  • использование очередей;
  • уменьшение параллелизма.

Поведение Dexie при cascade abort

Одна прерванная транзакция может влиять на другие через:

  • блокировку object store;
  • ожидание доступа;
  • цепочку зависимых промисов.

Dexie пытается изолировать транзакции, но IndexedDB модель накладывает ограничения: если один writer держит lock слишком долго, другие операции могут получить AbortError как побочный эффект.


Диагностика AbortError

При отладке важно учитывать:

  • момент возникновения (start / mid / commit);
  • наличие внешнего AbortController;
  • конкуренцию транзакций;
  • длительность операции;
  • stack trace (часто указывает на await внутри tx).

Логи Dexie можно использовать для выявления проблемных транзакций:

Dexie.debug = true;

Типовые анти-паттерны

  • выполнение fetch внутри transaction;
  • длинные циклы с await;
  • массовые параллельные write-транзакции;
  • отсутствие разделения read/write потоков;
  • использование одного store как глобального lock.

Каждый из этих паттернов увеличивает вероятность AbortError в условиях реальной нагрузки браузера.