Оренда пристроїв у Postgres: як не дати паралельним агентам битися за емулятори
Як одна таблиця в Postgres і короткий bash-скрипт не дають паралельним сесіям Claude Code стикатися на емуляторах і симуляторах, і три баги, які спіймали тести.
Одного разу запуск pkill -f maestro заодно вбив Android-емулятор, бо віртуальний пристрій називався maestro_e2e. Це було одне з трьох тихих зіткнень за одну сесію. Ліками стали таблиця в Postgres, bash-файл на 243 рядки й по три рядки в кожному тест-раннері.
Передісторію пояснює коміт, у якому з'явився реєстр. Було дев'ять worktree, сесії йшли паралельно, але кожен раннер поводився так, ніби він сам. Три зіткнення:
pkill -f "maestro"заодно валив Android-емулятор, бо ім'я AVDmaestro_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%, і ОС убила емулятор посеред прогону — ще одна річ, яка виглядає як нестабільність тестів.
Як застосувати у своєму репозиторії
- Використовуйте одну таблицю з ID пристрою як первинним ключем і захоплюйте одним
INSERT … ON CONFLICT … WHERE … RETURNING. - Звільняйте в trap на
EXIT INT TERMі не використовуйтеexecу раннері. - Відмовляйтеся працювати, коли сховище недоступне, з одним явним аварійним виходом.
- Перш ніж покладатися на блокування, проженіть тест конкуренції з N учасниками, перевіряйте порожній вивід і передавайте psql
-q. - Перш ніж вірити зеленому прогону на спільній машині, порахуйте хеш APK на пристрої й подивіться
uptime. - Запишіть правило туди, де його прочитає кожна сесія: реєстр, про який ніхто не знає, за замовчуванням обходять.
Що запам'ятати
Головне: паралельним агентам потрібне блокування навколо фізичних пристроїв, бо дві сесії на одному емуляторі дають збої, схожі на баги продукту. Блокуванню місце в первинному ключі — одним атомарним запитом, без кроку «прочитати, потім записати». Зелений тест конкуренції може брехати: INSERT 0 0 вважався успіхом, доки хтось не перевірив. Падайте закритими й називайте власника — тихий no-op гірший за відсутність реєстру. І нарешті, оренда — не остання лінія оборони: звірте хеш збірки, перевірте навантаження й лише тоді довіряйте прогону.