Протягом тижнів cron-завдання в Elevare Digital прокидалося за розкладом, перевіряло свою чергу та реєструвало успішне виконання. Воно схвалювало рівно нуль чернеток. Дев'ятнадцять одиниць контенту чекали на обробку. Команда дізналася про це лише пізніше, коли тиха пауза перетворилася з дивацтва на невелике накопичення завдань. Нічого не зламалося. Жодні сповіщення не спрацювали. Система була технічно справною, але функціонально мертвою.
У цьому полягає тихий жах автономних пайплайнів. Коли ви вилучаєте людину з циклу, ви також позбавляєтеся того, хто помітить, що нічого не відбувається.
Пайплайн, що працював сам по собі
Elevare Digital використовує повністю автоматизований робочий процес створення контенту. Програмні агенти генерують чернетки. Запланований cron-схвалювач виступає в ролі контролера, переглядаючи ці чернетки та відправляючи схвалені елементи безпосередньо на публікацію. Жодна людина не відкриває панель керування, щоб підтвердити кожну партію. Весь сенс у тому, щоб машина брала на себе рутину, поки команда займається іншими проблемами.
За такої моделі довіра стає вашим основним інтерфейсом. Ви довіряєте планувальнику, що він спрацює. Ви довіряєте завданню, що воно виконається. Ви довіряєте коду завершення. Коли логи показують стабільний ритм відповідей 200 OK, ви припускаєте, що робота просувається. Протягом тижнів цей ритм був ідеальним. Cron спрацьовував вчасно, щоразу. Він просто ніколи не виконував фактичну роботу.
Дев'ятнадцять чернеток і жодного сигналу тривоги
Відкриття було випадковим. Хтось зрештою помітив, що черга публікації затихла, або, можливо, перевірив метрику на наступному етапі й побачив плато. Те, що вони знайшли, було купою з дев'ятнадцяти чернеток, які залишилися зовсім недоторканими. Схвалювач працював сумлінно, щодня реєструючи успіх, але не обробив жодної з них.
У ручному робочому процесі людина-рецензент помітила б порожню скриньку вхідних повідомлень або накопичення очікуваних елементів вже в перший день. В автоматизованій версії відсутність активності виглядала точно так само, як відсутність роботи. У cron-завдання не було менеджера, якого можна було б розчарувати. Він просто відмічав час приходу і йшов додому раніше.
Два баги, один порожній результат
У цієї помилки було два «батьки». Жодна з них не була синтаксичною помилкою, таймаутом чи збоєм залежностей. Обидві були семантичними помилками, які в очах двигуна запитів перетворили дев'ятнадцять валідних рядків на ніщо.
По-перше, невідповідність типів. Агент, що генерує чернетки, записував записи з тегом article. Cron-схвалювач робив запит саме на типи thread. Це той вид розбіжностей, що виникає, коли виробники та споживачі розвиваються паралельними шляхами. Одна команда — або один агент — вирішила, що результатом є стаття. Інша написала споживача, припускаючи, що він прийматиме треди. Жодна система типів не видала помилку під час компіляції, оскільки це, ймовірно, були вільні рядкові теги, можливо, поля JSON або неконтрольовані значення varchar. База даних просто не знайшла збігів і повернула порожній набір. Для двигуна це не є помилковим станом. Це правильна відповідь на неправильне запитання.
По-друге, inner join у запиті схвалювача тихо поглинув усі рядки. Якщо запит об'єднував таблицю чернеток з іншою таблицею — можливо, для пошуку метаданих, прапорців статусу або правил маршрутизації — і умова об'єднання не виконувалася, inner join поводився саме так, як було задумано. Він виключав рядки, що не підпадали під умови. У результаті не з'явилося жодного «сирітського» рядка. Жодні null не вказали на проблему. Дев'ятнадцять чернеток пройшли крізь запит, як вода крізь сито, і рівень додатка отримав чистий порожній список.
Оскільки запит не повернув жодного рядка, функція завершилася успішно. Жодні винятки не прокинулися вгору. HTTP-відповідь була 200 OK. Cron зареєстрував успіх і знову пішов спати.
Пастка «оброблено: 0»
Ось у чому суть проблеми. У системі на основі черг споживач часто не знаходить жодного рядка для обробки. Черга порожня. Воркер завершує роботу швидко. У логах написано processed: 0, і команда сприймає це як хорошу новину: ми встигаємо за попитом. Це здоровий стан.
Але processed: 0 кодує дві абсолютно різні реальності:
- Здоровий стан: оброблено нуль, тому що в черзі нуль очікуваних завдань. Черга порожня. Система перебуває в режимі очікування за задумом.
- Зламаний стан: оброблено нуль, тому що споживач не бачить роботи. У черзі дев'ятнадцять рядків. Система не в режимі очікування, вона сліпа.
Без незалежної перевірки глибини черги ці два стани видають ідентичну телеметрію. Вони виглядають однаково в дашбордах, пахнуть однаково в агрегаторах логів і викликають таку ж тишу в PagerDuty. Ви побудували стратегію моніторингу, яка виявляє, коли воркер кричить, а не коли він мовчки проходить повз купу реальної роботи.
Подолання розриву
Elevare Digital fixed the problem by changing what they monitor. They stopped relying solely on error rates and success statuses. Instead, they started alerting on the gap between available work and completed work.
After every batch, they now run a simple invariant check:
- If processed is 0 and pending rows are greater than 0, trigger a high severity alert.
This rule is deliberately agnostic about cause. It does not care if the miss was a bad filter, a broken join, or a mistyped enum string. It cares only that work exists and no work got done. This shifts monitoring from “Did the process complain?” to “Did the work move?”
To support this, they treat queue depth as a first-class metric, tracked over time, not just as a spot-check. If the producer keeps adding rows while the consumer continuously reports success, the depth trend turns into a smoking gun. A static snapshot might lie, but a creeping backlog never does.
Lessons for Autonomous Systems
The Elevare incident contains a handful of practical rules for anyone running hands-off pipelines.
Log scanned rows separately from processed rows. The consumer might execute a query that touches forty rows, filters them all out through bad criteria, and reports processed: 0. If you only log the final count, you miss the ghost interaction. A scanned-rows metric reveals that the worker showed up, looked at the work, and walked away confused. That gap between scanned and processed is often your earliest signal.
Track queue depth as a time-series. A queue that is temporarily empty is fine. A queue that grows monotonically while workers stay green is not. Plot depth against consumer throughput. When the two diverge, investigate immediately, even if every health check is passing.
Test consumers against real producer output, not just mocks. Unit tests with mocked data carry the assumptions of the tester. If the mock factory produces thread types and the consumer expects thread types, your tests pass while production fails. Run integration tests that pull actual records from the producer’s output. Make sure the consumer can truly see what the producer writes.
Treat data types and enum values as contracts. Loose string tags in JSON blobs are convenient until they become invisible failure points. Define schemas explicitly. Share constants. Validate payloads at the seam between producer and consumer. If the contract breaks, the system should fail loudly at the boundary, not silently inside a WHERE clause.
The Real Takeaway
Autonomous systems do not fail like humans. They do not call in sick, throw exceptions every time, or leave obvious crash dumps. They return 200 OK and let the inventory rot. If your alerts only listen for screams, you will miss the most expensive failures—the ones where everything looks fine and nothing gets done.
Design your observability to watch the gap. Measure the work that enters against the work that exits. When the two no longer match, assume the machine is lying to you. Because sometimes, a perfect success log is the only symptom of a system that has gone completely blind.
