Wekenlang kwam een cronjob bij Elevare Digital volgens schema tot leven, controleerde de wachtrij en logde een succesvolle uitvoering. Hij keurde precies nul concepten goed. Negentien stukken content lagen te wachten. Het team kwam er pas later achter, nadat de stille stilte was uitgegroeid van een vreemdheid tot een kleine achterstand. Niets was gecrasht. Er gingen geen paging-alerts af. Het systeem was technisch gezond en functioneel dood.
Dit is de stille horror van autonome pipelines. Wanneer je de mens uit de loop haalt, haal je ook de persoon weg die opmerkt dat er niets gebeurt.
De pipeline die zichzelf draaide
Elevare Digital beheert een volledig geautomatiseerde content-workflow. Software-agents genereren concepten. Een geplande approver cron fungeert als poortwachter, die deze concepten beoordeelt en goedgekeurde items direct doorstuurt naar publicatie. Geen mens opent een dashboard om elke batch te zegenen. Het hele idee is dat de machine het routinematige werk doet, terwijl het team zich op andere problemen kan richten.
Onder dit model wordt vertrouwen je primaire interface. Je vertrouwt erop dat de scheduler wordt uitgevoerd. Je vertrouwt erop dat de job draait. Je vertrouwt op de exit code. Wanneer de logs een constante hartslag van 200 OK-responses laten zien, ga je ervan uit dat er werk wordt verzet. Wekenlang was die hartslag perfect. De cron werd elke keer op tijd uitgevoerd. Hij deed simpelweg nooit het eigenlijke werk.
Negentien concepten en geen alarm
De ontdekking was toevallig. Iemand merkte uiteindelijk op dat de publicatiewachtrij stil was geworden, of misschien controleerden ze een downstream-metriek en zagen ze een vlakke lijn. Wat ze vonden, was een verzameling van negentien concepten die volledig ongemoeid waren gelaten. De approver was plichtsgetrouw blijven draaien, logde elke dag succes, maar had er geen enkele verwerkt.
In een handmatige workflow zou een menselijke reviewer op de eerste dag al een lege inbox of een stapel openstaande items hebben opgemerkt. In de geautomatiseerde versie zag de afwezigheid van activiteit er precies hetzelfde uit als de afwezigheid van werk. De cron had geen manager om teleur te stellen. Hij bleef gewoon inchecken en ging vroeg naar huis.
Twee bugs, één leeg resultaat
De fout had twee oorzaken. Geen van beide was een syntaxfout, een timeout of een outage van een dependency. Het waren beide semantische fouten die negentien geldige rijen in de ogen van de query engine tot niets reduceerden.
Ten eerste: een type mismatch. De agent die concepten genereert, schreef records met het label article. De approver cron zocht specifiek naar thread-types. Dit is het soort drift dat ontstaat wanneer producers en consumers op parallelle trajecten evolueren. Het ene team — of één agent — besloot dat de output een artikel was. Een ander schreef de consumer in de veronderstelling dat deze threads zou verwerken. Geen enkel typesysteem gaf een compile-time error, omdat dit waarschijnlijk losse string-tags waren, misschien JSON-velden of niet-afgedwongen varchar-waarden. De database vond simpelweg geen overeenkomsten en gaf een lege set terug. Voor de engine is dat geen foutconditie. Het is een correct antwoord op een verkeerde vraag.
Ten tweede: een inner join in de query van de approver slokte de rijen stilletjes in zijn geheel op. Als de query de tabel met concepten koppelde aan een andere tabel — bijvoorbeeld voor een lookup van metadata, statusvlaggen of routingregels — en de join-conditie mislukte, gedroeg de inner join zich precies zoals ontworpen. Het sloot rijen uit die niet overeenkwamen. Er verschenen geen orphan rows in de resultaatset. Geen null-waarden gaven een probleem aan. De negentien concepten stroomden door de query als water door een zeef, en de applicatielaag ontving een perfecte, lege lijst.
Omdat de query geen rijen teruggaf, werd de functie netjes afgesloten. Er werden geen exceptions doorgegeven. De HTTP-response was 200 OK. De cron logde succes en ging weer slapen.
De valstrik van 'processed: 0'
Hier ligt de kern van het probleem. In een wachtrijgebaseerd systeem vindt een consumer vaak nul rijen om te verwerken. De wachtrij raakt leeg. De worker is snel klaar. In het log staat processed: 0 en het team leest dat als goed nieuws: we kunnen aan de vraag voldoen. Dat is een gezonde staat.
Maar processed: 0 codeert twee totaal verschillende realiteiten:
- Gezonde staat: Nul verwerkt omdat er nul in de wachtrij staat. De wachtrij is leeg. Het systeem is ontworpen om in ruststand te zijn.
- Defecte staat: Nul verwerkt omdat de consumer het werk niet kan zien. De wachtrij bevat negentien rijen. Het systeem is blind, niet in ruststand.
Zonder een onafhankelijke controle van de wachtrijdiepte zenden deze twee statussen identieke telemetrie uit. Ze zien er hetzelfde uit in dashboards, ruiken hetzelfde in log-aggregators en veroorzaken dezelfde stilte in PagerDuty. Je hebt een monitoringstrategie gebouwd die detecteert wanneer de worker schreeuwt, niet wanneer hij geruisloos langs een berg echt werk glipt.
Het gat dichten
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.
