Главная / Статьи / Аренда устройств в Postgres: как не дать параллельным агентам драться за эмуляторы

Аренда устройств в Postgres: как не дать параллельным агентам драться за эмуляторы

Как одна таблица в Postgres и короткий bash-скрипт не дают параллельным сессиям Claude Code сталкиваться на эмуляторах и симуляторах, и три бага, которые поймали тесты.

1293 слов

Однажды запуск pkill -f maestro заодно убил Android-эмулятор, потому что виртуальное устройство называлось maestro_e2e. Это было одно из трёх тихих столкновений за одну сессию. Лекарством стали таблица в Postgres, bash-файл на 243 строки и по три строки в каждом тест-раннере.

Предысторию объясняет коммит, в котором появился реестр. Было девять worktree, сессии шли параллельно, но каждый раннер вёл себя так, будто он один. Три столкновения:

  • pkill -f "maestro" заодно ронял Android-эмулятор, потому что имя AVD maestro_e2e подходило под шаблон.
  • Симуляторы загружали, стирали и удаляли, не проверяя, не гоняет ли на них тесты другая сессия.
  • Когда две сессии делят одно устройство, сбой выглядит как баг в продукте. Это самое дорогое столкновение: в симптомах ничто не указывает на настоящую причину.

Сейчас в репозитории одиннадцать worktree. Каждая сессия Claude Code получает свой, многие работают одновременно, и все пользуются одними и теми же двумя Android-эмуляторами, настоящим Galaxy Z Fold через Samsung Remote Test Lab и двумя iOS-симуляторами. Никакого механизма, который бы их разводил, не было.

Одна таблица, и первичный ключ — это и есть блокировка

Реестр — это одна таблица Postgres в отдельной базе yarina_devtools. Это намеренно не схема внутри базы приложения: она не должна попадать в миграции, дампы и бэкапы приложения, а удалять её должно быть не страшно.

CREATE TABLE IF NOT EXISTS device_lease (
  device_id   text PRIMARY KEY,
  platform    text NOT NULL CHECK (platform IN ('ios', 'android')),
  worktree    text NOT NULL,
  session_id  text NOT NULL,
  holder_pid  integer NOT NULL,
  purpose     text NOT NULL DEFAULT '',
  acquired_at timestamptz NOT NULL DEFAULT now(),
  expires_at  timestamptz NOT NULL
);

Всю работу по атомарности делает первичный ключ. Захват — это один запрос: INSERT … ON CONFLICT (device_id) DO UPDATE … WHERE текущая строка принадлежит той же сессии или истекла, затем RETURNING session_id. Postgres сериализует конкурентные захваты по этому ключу, поэтому из N гонщиков выигрывает ровно один. Если бы в дизайне была таблица истории с флагом active, вопрос «свободно ли устройство?» превратился бы в отдельный запрос — и именно туда пролезла бы гонка между претендентами.

Прежде чем трогать устройство, каждый раннер делает одни и те же три вещи:

source "$REPO_ROOT/e2e/scripts/device-lease.sh"
trap 'lease_release_all' EXIT INT TERM
lease_claim "$DEVICE" android "android $SUITE" || exit 1

При отказе скрипт показывает, кто держит устройство (worktree, сессия, цель, как долго), и команду, чтобы его освободить. Для проверки живости скрипт выполняет kill -0 по PID владельца. Postgres не может знать, жив ли процесс, но всё работает на одной машине, так что спросить может вызывающая сторона. TTL в 4 часа закрывает случай переиспользованного PID. Таблица создаётся при первом захвате: создавать её при старте сессии пробовали и отказались, потому что большинству сессий устройство вообще не нужно.

Три бага, которые нашли тесты

В реальной работе они бы никогда не всплыли.

psql печатает тег команды. Отказ в захвате возвращает ноль строк — это и есть отказ. Но без -q psql выводил в stdout INSERT 0 0, проверка на пустой вывод видела текст, и каждый тест конкуренции считался успешным. Поймал это тест конкуренции, а в повседневной работе баг бы так и остался незамеченным.

Проверять живость до захвата — значит устроить гонку. Первая версия одним запросом искала PID текущего владельца и передавала результат во вставку. Восемь одновременных претендентов не видели владельца, решали, что устройство свободно, и все восемь «выигрывали». Теперь порядок обратный: сначала пробуем захват, никогда не отбирая у живого владельца, и только после отказа смотрим на владельца и удаляем его через compare-and-swap по (holder_pid, acquired_at), а потом пробуем ещё раз. Из восьми выигрывает один.

Захват из CLI, записывающий собственный PID, умирает в момент выхода. Поэтому ручные захваты записывают holder_pid 0, то есть без процесса-владельца, и когда они истекут, решает TTL.

Четвёртую проблему добавил один из раннеров. С exec maestro … процесс шелла заменяется, trap на EXIT не срабатывает, и аренда остаётся после окончания прогона. Вызывайте maestro как обычную команду. Trap обязателен: если аренда освобождается только при успехе, устройство зависает после упавших прогонов — а именно их чаще всего тут же запускают заново.

Падать закрытым и честно называть дыры

Если Postgres недоступен, раннер отказывается продолжать и объясняет почему. Тихо деградировать до no-op хуже, чем не иметь реестра вовсе: оператор думает, что защищён, а это не так. YARINA_DEVICES_OPTIONAL=1 — задокументированный способ продолжить без защиты.

Прямой вызов maestro test --device X полностью обходит аренду. Обёртку-шим поверх maestro, simctl и adb рассматривали и отвергли: она навязчивая и легко обходится, стоит чему-то вызвать настоящий бинарник. Так что границей служат раннеры, а правило записано в CLAUDE.md — единственный файл, который читает каждая сессия.

Аренда не делает зелёный прогон правдой

Общие устройства потребовали ещё двух проверок.

Проверяйте сборку по хешу. Три зелёных прогона на самом деле тестировали APK, установленный другой сессией. Повторный прогон сравнивал хеш APK на устройстве (pm path, затем shasum) с локальной сборкой — до и после. Теперь это постоянное правило для любого общего устройства: успешный сценарий ничего не говорит о коде, которого на устройстве нет.

Следите за нагрузкой машины. Код, проходивший при нагрузке около 8, падал при нагрузке около 20 на таймаутах повторов, без всяких изменений. Два симулятора и Android-эмулятор вместе подняли load average до 94 и 16 раз уронили SpringBoard, поэтому iOS-раннер теперь не стартует выше порога нагрузки. Когда эмулятору дали больше RAM, своп дошёл до 93%, и ОС убила эмулятор посреди прогона — ещё одна вещь, которая выглядит как нестабильность тестов.

Как применить у себя

  1. Используйте одну таблицу с ID устройства в качестве первичного ключа и захватывайте одним INSERT … ON CONFLICT … WHERE … RETURNING.
  2. Освобождайте в trap на EXIT INT TERM и не используйте exec в раннере.
  3. Отказывайтесь работать, когда хранилище недоступно, с одним явным аварийным выходом.
  4. Прежде чем полагаться на блокировку, прогоните тест конкуренции с N гонщиками, проверяйте пустой вывод и передавайте psql -q.
  5. Прежде чем верить зелёному прогону на общей машине, посчитайте хеш APK на устройстве и посмотрите uptime.
  6. Запишите правило туда, где его прочитает каждая сессия: о реестре, про который никто не знает, по умолчанию забывают.

Что запомнить

Главное: параллельным агентам нужна блокировка вокруг физических устройств, потому что две сессии на одном эмуляторе дают сбои, которые выглядят как баги продукта. Блокировке место в первичном ключе — одним атомарным запросом, без шага «прочитать, потом записать». Зелёный тест конкуренции может врать: INSERT 0 0 считался успехом, пока кто-то не проверил. Падайте закрытыми и называйте владельца — тихий no-op хуже отсутствия реестра. И наконец, аренда — не последняя линия обороны: сверьте хеш сборки, проверьте нагрузку и только потом доверяйте прогону.