Обработка дублирующихся ключей при пакетных операциях

Поведение IndexedDB при нарушении уникальности ключей

В основе Dexie.js лежит IndexedDB, где каждая таблица опирается на первичный ключ и, при наличии, уникальные индексы. Любая попытка вставки данных с уже существующим ключом приводит к нарушению ограничения уникальности.

В пакетных операциях это проявляется особенно резко: при массовой вставке даже одного конфликтующего элемента может измениться результат всей операции.

Основные источники конфликтов:

  • первичный ключ объекта (keyPath или автоинкремент)
  • уникальные индексы (unique index)
  • составные ключи (compound keys)
  • повторная вставка одинаковых сущностей в одном массиве

Пакетные операции Dexie.js и их семантика

Dexie предоставляет два основных метода массовой записи:

  • bulkAdd() — строгая вставка новых записей
  • bulkPut() — вставка с заменой существующих

Ключевое различие заключается в поведении при столкновении с дубликатами.

bulkAdd():

  • требует уникальности всех ключей
  • при конфликте генерирует ошибку
  • прекращает операцию частично или полностью (в зависимости от ситуации и транзакции)

bulkPut():

  • выполняет upsert-логику (ins ert + upd ate)
  • безопасен при наличии дублей по ключу
  • не гарантирует предотвращение перезаписи данных

Природа дубликатов в пакетной вставке

Дубликаты могут возникать на нескольких уровнях:

  1. Внутри входного массива

    table.bulkAdd([
      { id: 1, name: "A" },
      { id: 1, name: "B" }
    ]);

    Конфликт происходит ещё до обращения к базе.

  2. С уже существующими данными

    table.bulkAdd([
      { id: 1 },
      { id: 2 }
    ]);

    Если id=1 уже существует — возникает нарушение уникальности.

  3. По уникальным индексам Даже при уникальном primary key возможны конфликты:

    db.users.defineIndex("email", "email", { unique: true });

Модель ошибок Dexie при массовых операциях

При конфликте Dexie формирует специализированную ошибку:

  • Dexie.BulkError

Она содержит:

  • failures — массив элементов, которые не удалось вставить
  • inner — первичную IndexedDB ошибку (ConstraintError)
  • частичное состояние выполнения операции

Типичный сценарий:

  • часть записей успешно добавлена
  • часть отклонена из-за дубликатов

Это делает пакетные операции частично атомарными только в рамках транзакции.

Поведение транзакций при bulkAdd

При использовании транзакции:

db.transaction("rw", db.table, async () => {
  await db.table.bulkAdd(items);
});

при выбросе исключения:

  • транзакция откатывается целиком
  • ни одна запись не сохраняется

Без транзакции возможен частичный коммит.

Стратегии обработки дубликатов

1. Предварительная дедупликация входных данных

Самый дешёвый по ресурсам подход — очистка массива до вставки.

const unique = Array.from(
  new Map(items.map(item => [item.id, item])).values()
);

Поведение:

  • сохраняется последний элемент
  • устраняются внутренние конфликты

Недостаток:

  • не учитывает состояние базы

2. Использование bulkPut вместо bulkAdd

await table.bulkPut(items);

Поведение:

  • при наличии ключа запись обновляется
  • отсутствует ошибка дубликата

Особенности:

  • не подходит для строгого режима “только новые записи”
  • может непреднамеренно перезаписывать данные

3. Построчная фильтрация перед вставкой

Используется при необходимости строгого контроля:

const existingIds = await table.where("id").anyOf(ids).primaryKeys();
const existingSet = new Se t(existingIds);

const filtered = items.filter(x => !existingSet.has(x.id));

await table.bulkAdd(filtered);

Плюсы:

  • контроль над тем, что именно вставляется

Минусы:

  • дополнительный запрос к базе
  • риск гонки данных при параллельных операциях

4. Обработка BulkError

При частичной вставке важно разбирать результат:

try {
  await table.bulkAdd(items);
} catch (e) {
  if (e.name === "BulkError") {
    console.log("Ошибки вставки:", e.failures);
  }
}

Структура failures содержит:

  • индекс элемента в исходном массиве
  • причину ошибки
  • сам объект данных

Это позволяет реализовать повторную обработку:

  • повторная попытка только неуспешных элементов
  • логирование конфликтов
  • выборочное обновление через bulkPut

5. Смешанная стратегия insert-or-ignore

Комбинация add и игнорирования конфликтов:

for (const item of items) {
  try {
    await table.add(item);
  } catch (e) {
    if (e.name !== "ConstraintError") throw e;
  }
}

Используется редко из-за низкой производительности, но даёт точный контроль.


6. Разделение входных данных на “новые” и “существующие”

Оптимизированный вариант:

const ids = items.map(x => x.id);
const existing = await table.where("id").anyOf(ids).primaryKeys();
const existingSet = new Se t(existing);

const toAdd = [];
const toUpdate = [];

for (const item of items) {
  if (existingSet.has(item.id)) {
    toUpdate.push(item);
  } else {
    toAdd.push(item);
  }
}

await table.bulkAdd(toAdd);
await table.bulkPut(toUpdate);

Характер поведения:

  • строгая семантика вставки
  • предсказуемый контроль конфликтов
  • минимизация ошибок выполнения

Дубликаты в рамках уникальных индексов

Даже при уникальном primary key дубликаты могут возникать через вторичные индексы:

db.users = new Dexie.Table({
  id: "++id",
  email: { unique: true }
});

При пакетной вставке:

  • нарушение уникального индекса приводит к ConstraintError
  • Dexie оборачивает это в BulkError

Особенность:

  • конфликт может возникнуть не на первом элементе с дублем, а позже по ходу операции

Особенности автоинкремента и дублей

При использовании ++id:

  • Dexie сам генерирует ключ
  • дубликаты чаще возникают на уровне уникальных индексов
  • при повторной вставке без id конфликтов меньше, но они не исключены

Поведение при частичной успешности операций

В пакетных методах возможны состояния:

  • все записи успешны
  • часть записей записана, часть отклонена
  • полная неудача (в транзакции)

Важно учитывать:

  • порядок failures соответствует входному массиву
  • успешные записи не откатываются вне транзакции
  • поведение зависит от контекста вызова

Оптимизация массовых вставок с учётом дублей

Для высоконагруженных сценариев применяются следующие принципы:

  • минимизация обращений к базе перед вставкой
  • использование bulkPut вместо сложной логики проверки
  • предварительная нормализация данных
  • батчинг (разбиение на порции)
  • контроль уникальности на уровне приложения

Пример батчинга:

const chunkSize = 500;

for (let i = 0; i < items.length; i += chunkSize) {
  const chunk = items.slice(i, i + chunkSize);
  await table.bulkPut(chunk);
}

Конфликтная модель “last write wins”

При использовании bulkPut или комбинированной стратегии система фактически переходит в модель:

  • последняя запись перезаписывает предыдущую
  • порядок входного массива критичен
  • параллельные транзакции могут приводить к неочевидным результатам

Контроль целостности данных при пакетной вставке

Ключевые принципы:

  • выбор стратегии (strict insert vs upsert) задаётся на уровне API
  • дублирование данных должно обрабатываться до обращения к IndexedDB
  • ошибки BulkError требуют постобработки
  • уникальные индексы всегда имеют приоритет над логикой приложения

Работа с дублирующими ключами в Dexie.js сводится к управлению компромиссом между производительностью пакетных операций и строгостью контроля целостности данных.