Jepsen: Orkes Conductor 5.3.2

Orkes Conductor is een workflow-orchestratieplatform dat door de gebruiker gedefinieerde taken uitvoert en gegevens tussen deze taken routeert. We hebben Orkes Conductor-versies 5.3.2 tot en met 5.5.0-rc2 getest en vonden een onverwacht frequente gelijktijdige uitvoering van individuele taken, één probleem dat de uitvoering van workflows aanzienlijk kan vertragen, vier kleine problemen met startWorkflow-fouten of null-ID's, en drie ernstige problemen waardoor Conductor workflow-uitvoeringen permanent negeerde of vergat, of ze als voltooid markeerde terwijl de taken nooit waren uitgevoerd.

Orkes heeft versie 5.5.6 uitgebracht met patches voor alle ernstige veiligheidsproblemen, waarbij ook de gelijktijdige uitvoering is verminderd en twee startWorkflow-fouten zijn opgelost. De overige twee startWorkflow-fouten worden onderzocht. De vertraagde uitvoering is consistent met de ontwerpparameters van Conductor en hoeft niet te worden aangepakt. Orkes heeft een begeleidend blogbericht bij dit werk gepubliceerd. Dit onderzoek is gefinancierd door Orkes Inc. en uitgevoerd in overeenstemming met het ethische beleid van Jepsen.

1 Achtergrond

Een workflow-orchestrator is een softwaresysteem dat berekeningen uitvoert die bestaan uit kleine taken. Taken zijn verbonden in een workflow, die de output van sommige taken naar de input van andere routeert, bepaalt welke taken kunnen worden gepland en wanneer, enzovoort. De orchestrator beheert workflows en specifieke uitvoeringen daarvan, voert hun taken uit, houdt de status bij en probeert elke uitvoering naar voltooiing te leiden.

Conductor is een workflow-systeem dat oorspronkelijk is gebouwd door Netflix en in 2016 open source is gemaakt. De open source-versie, Conductor OSS, wordt nu onderhouden door de Conductor OSS Foundation. Deze stichting wordt geleid door Orkes, wiens oprichters hielpen bij het bouwen van Conductor bij Netflix. Orkes biedt ook een hosted versie van Conductor aan met extra functies, genaamd Orkes Conductor. Deze analyse richt zich op Orkes Conductor, dat we voor het gemak Conductor noemen.

In Conductor is een workflow-definitie een JSON-structuur die definieert welke taken moeten worden uitgevoerd, hun volgorde, wat hun input en output moeten zijn, enzovoort. Elke workflow-definitie wordt geïdentificeerd door een naam en een versie. Bijvoorbeeld: een workflow-definitie zou kunnen specificeren dat om een kat te adopteren, men eerst een aanvraag moet invullen, vervolgens een bezoek aan de dierenarts moet plannen en vaccinatiedocumenten van de dierenarts moet ontvangen. Een workflow kan ook een input accepteren (bijvoorbeeld de naam van de kat) en een output produceren (zoals een adoptiecertificaat).

Een workflow-definitie kan vele malen worden uitgevoerd—bijvoorbeeld één keer voor elke kat die wordt geadopteerd. Elke instantie wordt een workflow-uitvoering (workflow execution) genoemd, maar we noemen een uitvoering vaak kortweg een "workflow". Elke uitvoering wordt geïdentificeerd door een uniek workflow-ID. Tijdens een uitvoering kan een taak ook meerdere keren worden geprobeerd (bijvoorbeeld in geval van een fout), en elke poging krijgt een uniek uitvoerings-ID.

Sommige taken zijn ingebouwd in Conductor. Sommige zijn systeemtaken, zoals het maken van HTTP-verzoeken of het verzenden van gebeurtenissen naar een log zoals Kafka. Andere worden operators genoemd—deze bieden control flow zoals voorwaarden, fork/join, loops, enzovoort. Operators kunnen ECMAScript-code uitvoeren om door de gebruiker gedefinieerde logica uit te voeren. Ten slotte zijn er worker-taken, die door gebruikers worden geschreven om de "business logica" van het systeem uit te voeren.

Om een uitvoering van een workflow te starten, kan men een API-aanroep naar Conductor doen, een gebeurtenis via een message broker indienen of de ingebouwde cron-stijl scheduler van Conductor gebruiken. Zodra dit is bevestigd, plant Conductor de taken van de workflow in en probeert deze naar voltooiing te leiden.

1.1 Architectuur

Conductor bestaat uit vijf belangrijke componenten. Conductor-servers zijn stateless processen die workflows beheren en de uitvoering plannen. Ze bieden een API en gebruikersinterface, communiceren met externe services en houden de status bij. De definities van workflows, de status van hun uitvoeringen en de resultaten van taken worden opgeslagen in een database zoals Postgres, Cassandra of Redis.

Conductor gebruikt ook een queue—vaak Redis—om werk naar verschillende processen te sturen en om kortstondige status op te slaan. Ten slotte zijn er twee pools van stateless workers: standaard workers die de ingebouwde taken van Conductor uitvoeren, en custom workers, die door gebruikers zijn geschreven met SDK's voor diverse talen. Alle workers maken verbinding met een Conductor-server, pollen de server voor taken, voeren deze uit en sturen de resultaten terug naar de server.

Elke component kan onafhankelijk worden geschaald. Elk aantal servers en workers kan gelijktijdig draaien, en nodes kunnen op elk moment worden beëindigd en herstart. In versie 5.3.2 was het vereist dat zowel de database als de queue alle bevestigde schrijfacties bewaarden. Versie 5.4.0 introduceerde echter een nieuwe optie waarmee het gebruik van een lossy opslagsysteem mogelijk werd voor alle queues, behalve voor de belangrijkste decider queue.

1.2 Garanties

Conductor garandeert "minstens één keer berichtaflevering" (at least once message delivery), wat ervoor zorgt dat alle berichten persistent zijn en één of meer keren aan taak-workers worden afgeleverd. Conductor garandeert ook dat workflows uiteindelijk beëindigen: "Als een workflow is gestart, zal deze tot voltooiing lopen, zolang alle taken ervan zijn voltooid." Dit betekent niet noodzakelijkerwijs dat workflows en taken succesvol worden uitgevoerd. Taken kunnen hun aantal herpogingen verbruiken, falen met niet-herstelbare fouten, of expliciet worden geannuleerd—en een soortgelijke state machine is van toepassing op workflow-uitvoeringen. Workflows zouden echter uiteindelijk moeten convergeren naar een terminale status: COMPLETED, FAILED, TIMED_OUT, of TERMINATED.

Wanneer taken worden uitgevoerd, krijgt elke poging een uniek uitvoerings-ID en een unieke retry count, die het aantal reeds uitgevoerde pogingen aangeeft.

Orkes stelt dat voor elke workflow-uitvoering elke instantie van een taak maximaal één zichtbare succesvolle uitvoering heeft. Taken kunnen meerdere keren succesvol worden uitgevoerd—bijvoorbeeld als een Conductor-server de verbinding met de database verliest vlak voordat de voltooiing van de taakuitvoering wordt vastgelegd. Echter, zodra de output van een succesvolle taak zichtbaar wordt, zal geen enkele taak, workflow of API-aanroep een andere output waarnemen.

2 Testontwerp

Om deze eigenschappen te verifiëren, hebben we een testomgeving gebouwd voor Orkes Conductor met de Jepsen-testbibliotheek en de Conductor Java SDK versie 5.0.1. We hebben deze omgeving gebruikt om Orkes Conductor 5.3.2, 5.4.0 en 5.5.0-rc1 en -rc2 te testen.

Orkes implementeert Conductor doorgaans in cloudomgevingen met Postgres (of een Postgres-compatibele database, zoals hun eigen ParticleDB) als primaire database, en Redis (of een compatibele service zoals Valkey) als queue. Om deze implementaties te benaderen, hebben we clusters opgezet van tien Debian-machines, zowel in containers als als aparte EC2 VM's: één Postgres-node voor de database, ofwel één Redis-node of een Redis Cluster voor de queue, en de overige nodes gelijkmatig verdeeld over Conductor-servers, standaard workers van Conductor en een custom worker die we hebben geschreven met de Java SDK.

Onze testwerklast definieerde een enkele workflow met één taak, afgehandeld door onze custom worker. De taak sliep tot 30 seconden om werk te simuleren en gaf vervolgens een willekeurig gegenereerde output terug. Ook logde de taak zijn uitvoering bij de testomgeving, zodat we precies konden zien welke taken waren uitgevoerd en wanneer, samen met hun waargenomen inputs en beoogde outputs.

Onze test verliep in drie fasen. Tijdens de hoofdfase riep de testomgeving startWorkflow() aan met unieke inputs (om uitvoeringen van elkaar te onderscheiden), hield bij welke uitvoeringen in behandeling waren en welke terminaal waren, en controleerde de status van specifieke uitvoeringen met getWorkflow(). Om uitvoeringen te ontdekken wanneer de aanroep naar startWorkflow() time-outte of faalde, riepen we periodiek getRunningWorkflow() aan om alle lopende uitvoeringen op te sommen. Tijdens de hoofdfase injecteerden we ook diverse fouten, waaronder het pauzeren en doden van processen, evenals netwerkpartities tussen Conductor-servers en Postgres, Redis en worker-nodes.

Tijdens de herstelfase beëindigde de test netwerkpartities, herstartte en ontpauzeerde processen, etc., en liet het cluster herstellen. Om te bepalen wanneer het systeem was geconvergeerd naar een stabiele toestand, pollden we de diepte van de decider queue van Conductor. Wanneer deze queue nul bereikte, had Conductor niets meer te doen en ging de test over naar de laatste fase.

Tijdens de laatste fase las de test de status van elke workflow-uitvoering die het ooit had gezien. We zochten vervolgens naar verschillende onverwachte gedragingen:

  • Backend Error: Een verzoek retourneerde een onverwachte backend-fout.
  • Concurrent Execution: Twee of meer workers probeerden tegelijkertijd dezelfde taak in een enkele workflow-uitvoering uit te voeren.
  • Different Task Input: Twee weergaven van dezelfde taak in dezelfde workflow-uitvoering hadden verschillende inputs, of de input van een taak was inconsistent met de input van de workflow.
  • Different Workflow Input: Een enkele workflow-uitvoering had twee verschillende inputs.
  • Different Workflow Output: Een enkele workflow-uitvoering had twee verschillende outputs.
  • Duplicate Workflow ID: Twee aanroepen naar startWorkflow retourneerden hetzelfde workflow-uitvoerings-ID.
  • G1a: Een startWorkflow-verzoek retourneerde wat leek op een definitieve fout, maar een workflow met die gevraagde input was later zichtbaar voor een reader.
  • Impossible Output: De output van een workflow-uitvoering was inconsistent met de outputs van de taken.
  • Incomplete: Een workflow-uitvoering was in de laatste fase niet in de status COMPLETED.
  • Missing: Een leesactie slaagde er niet in een workflow-uitvoering waar te nemen nadat we wisten dat deze bestond.
  • Never Started: Een workflow ontstond zonder bijbehorend startWorkflow-verzoek.
  • Null Workflow ID: Een startWorkflow-aanroep retourneerde null in plaats van een workflow-ID string.
  • Task Never Executed: Geen van de taken van een workflow werd ooit uitgevoerd.

3 Resultaten

3.1 Taken worden gelijktijdig uitgevoerd (#1)

Onder normale omstandigheden voerden gezonde Conductor-clusters af en toe dezelfde taak voor dezelfde workflow-uitvoering gelijktijdig uit op twee workers. Dit is niet verrassend, aangezien wederzijdse uitsluiting (mutual exclusion) onmogelijk is in asynchrone netwerken. Het is echter belangrijk om te onthouden dat zelfs zeer kortstondige taken tegelijkertijd kunnen worden uitgevoerd. In deze testrun begon de enige taak van workflow 3919784b-7691-11f1-a292-00163e983f22 op worker n9 op 70,110 seconden, en vervolgens op worker n10 op 70,113 seconden. Hij werd voltooid op n9 op 70,114 seconden, en op n10 minder dan een milliseconde later.

De documentatie voor Conductor OSS beschrijft een gedistribueerd lockingsysteem dat "race-condities voorkomt wanneer meerdere serverinstanties gelijktijdig dezelfde workflow evalueren". Orkes Conductor heeft ook een lockingsmechanisme waarvan de lease-tijd wordt geconfigureerd door conductor.workflow.lockLeaseTime. Hoewel deze lock de frequentie van gelijktijdige uitvoering kan verminderen, kan hij deze niet volledig voorkomen.

Met onze single-task workflows, de standaard lease-tijd van één minuut, twee Conductor-servers, drie workers, bijna nul netwerklatentie, kortstondige taken (p50 ~500 µs, p95 ~5 ms, p99 ~10 ms) en ongeveer 1.100 workflows per seconde, observeerden we gelijktijdige uitvoering ongeveer één keer per zes uur in gezonde clusters. Het verlagen van de lease-tijd naar één seconde maakte gelijktijdige uitvoering elke paar minuten mogelijk. De frequentie in productie kan variëren.

Dit probleem werd verergerd door een time of check to time of use (TOCTOU) bug in het sweeper-proces dat vastgelopen taken opnieuw in de queue plaatst. Stel dat een sweeper een taak leest in status SCHEDULED. Een worker voert die taak vervolgens uit, waardoor de status in de database verandert in IN_PROGRESS en het taakbericht uit de queue wordt verwijderd. De sweeper controleert vervolgens de queue en vindt geen bericht voor de (niet langer) geplande taak; hij plaatst deze opnieuw in de queue, wat leidt tot dubbele aflevering. Orkes meldt dat dit probleem (#1) is opgelost in versie 5.5.6.

Jepsen suggereert bovendien dat Orkes de Conductor-documentatie bijwerkt om duidelijk te maken dat taken gelijktijdig kunnen worden uitgevoerd. Het is ook mogelijk dat een worker retryCount = 0 observeert (wat suggereert dat hij de eerste was die de taak uitvoerde), terwijl een andere worker die taak al succesvol had uitgevoerd.

3.2 Taken stagneren vóór uitvoering (#2)

Bij gebruik van een Postgres primaire DB en Redis als queue, liet Orkes Conductor 5.3.2 vaak een handvol workflows stagneren vóór de taakuitvoering. Tijdens het stagneren zou elke workflow de status RUNNING vertonen, en de taak van de workflow zou verschijnen alsof deze op een specifieke worker draaide. Echter, de execute()-methode van de worker werd nooit aangeroepen voor die taak. Deze status hield aan tot de response-timeout van de taak werd geactiveerd, waarna Conductor de taak opnieuw zou inplannen. We observeerden dit gedrag als reactie op een breed scala aan fouten—bijvoorbeeld als Redis werd gedood of gepauzeerd; of Postgres werd gedood; of een server werd gedood of gepartitioneerd van Redis, Postgres of een worker; of als een worker werd gedood.

Bijvoorbeeld, beschouw deze testrun, waarin twee van de 20 workflow-uitvoeringen stagneerden. Workflow-uitvoering 3f71d24a-7619-11f1-b911-00163eb95b8c werd bevestigd door server n3 om 08:23:43.588, de enige taak werd gepland om 08:23:43.593, en geregistreerd als IN_PROGRESS op worker n10 om 08:23:44.615. Echter, worker n10 riep nooit Worker.execute(task) aan met dat workflow-ID. Server n3 leek normaal te functioneren tot 08:24:42, toen hij een read timeout tegen Redis logde.

In het algemeen zou een pauze, crash of partitie die een Conductor-server isoleert van Redis, steevast een handvol workflows een uur lang laten stagneren—de standaard response-timeout. Gebruikers kunnen de parameter responseTimeoutSeconds verlagen om de vertraging te verkorten. Aangezien de taken uiteindelijk werden uitgevoerd binnen het tijdsbestek van de response-timeout, schendt dit niet de geclaimde garanties van Conductor. Orkes koos ervoor dit gedrag niet te wijzigen. (#2).

3.3 Null Workflow ID's (#3)

Bij het maken van een workflow retourneerde de startWorkflow()-methode van de Java-client normaal gesproken een workflow-ID als een string. Echter, in zeldzame gevallen retourneerde deze null, zoals in deze testrun waarbij het Redis-proces werd gedood. Orkes onderzoekt dit probleem (#3). Hoewel de grondoorzaak onbekend is, bevat versie 6.0.2 van de Java-client een mitigatie.

3.4 Syntaxfout (#4)

Af en toe, in gezonde clusters zonder fouten, retourneerden aanroepen naar startWorkflow een syntaxfout. Er leek niets ongewoons te zijn aan de verzoeken: ze boden precies hetzelfde soort input als elke andere aanroep naar startWorkflow(). Hier is een typisch voorbeeld:

{:type    :invoke,
 :process 12,
 :f       :start-workflow,
 :value   {:name "greetings",
           :version 1,
           :input {"name" "jepsen 10"}}}

Dit veroorzaakte: BACKEND_ERROR - ERROR: syntax error at or near \"null\"\n Position: 13.

Conductor bewaart workflow-uitvoeringen in een tabel die is gepartitioneerd op datum. Wanneer een nieuwe partitie werd aangemaakt of geroteerd, zorgde een race-conditie ervoor dat de partitiemanager een tijdelijk lege map van shards naar tabellen kon waarnemen. Conductor zocht vervolgens de shard in die lege map en construeerde een SQL-statement zoals INSERT INTO null ..... Postgres gaf een syntaxfout, en dat foutbericht werd teruggestuurd naar de client. In versie 5.5.6 zorgt Conductor ervoor dat de shard-map is geïnitialiseerd voordat schrijfacties worden geaccepteerd, wat dit probleem zou moeten voorkomen (#4).

3.5 Duplicate Key Error (#8)

In de release candidate-build 5.5.0-rc1 kon het starten van een workflow zeer zelden een Postgres duplicate key fout retourneren. Neem bijvoorbeeld deze testrun met Postgres-kills en pauzes. Een enkele aanroep naar startWorkflow retourneerde deze fout:

BACKENDERROR - ERROR: duplicate key value violates unique constraint \"workflowarchiveshard00pkey\"\n Detail: Key (orgid, workflow_id)=(0000, 72e49555-81eb-11f1-8ccb-00163eb95b8c) already exists.

Dit lijkt te zijn veroorzaakt door een retry-mechanisme dat aan 5.5.0-rc1 was toegevoegd: wanneer een Conductor-server er niet in slaagde een nieuw workflow-uitvoeringsrecord in Postgres in te voegen, probeerde hij het opnieuw, wat in conflict kon komen met zijn vorige schrijfactie. Dat foutbericht werd vervolgens naar de client gestuurd. In versie 5.5.6 zou Conductor fouten met dubbele ID's bij retries moeten negeren (#8).

3.6 Aborted Read (#6)

In 5.3.2, 5.4.0, 5.5.0-rc1 en 5.5.0-rc2 zorgde het doden van Conductor-servers bij gebruik van een Postgres-database en een Redis-queue er vaak voor dat aanroepen naar startWorkflow() een fout gaven zoals Failed to connect to n3/10.0.3.103:8080. Dit klinkt als een definitieve fout—immers, als de client geen verbinding kon openen, hoe zou hij dan de workflow kunnen starten? Veel van deze workflows werden echter daadwerkelijk uitgevoerd door workers en waren zichtbaar via getWorkflow-aanroepen. In deze korte run waren 1.752 van de 25.920 mislukte aanroepen naar startWorkflow eigenlijk succesvol, zoals deze:

{:index 65834,
 :time 11146587755,
 :type :fail,
 :process 24,
 :f :start-workflow,
 :value {:name "greetings",
         :version 1,
         :input {"name" "jepsen 8568"}},
 :error [:failed-to-connect "Failed to connect to n3/10.0.3.103:8080"]}

De unieke input voor deze workflow, "jepsen 8568", verscheen in latere leesacties van workflow 559cb7ea-7f07-11f1-9dce-00163eb95b8c. In feite had de client een succesvol verzoek verzonden om deze workflow te maken, maar had hij, wellicht door een client-side timeout, besloten het verzoek intern opnieuw te proberen. Wanneer die retry er niet in slaagde een verbinding te openen, retourneerde deze alleen die meest recente fout, in plaats van een fout die de eerdere request-timeout weerspiegelde. Orkes heeft de Java-client gewijzigd om het opnieuw proberen van niet-idempotente verzoeken te voorkomen, en onderzoekt of er een isDefinite() methode aan client-fouten moet worden toegevoegd (#6).

3.7 Abandoned Workflows (#5)

In versies 5.3.2, 5.4.0 en 5.5.0-rc1 konden diverse fouten ervoor zorgen dat Conductor sommige workflow-uitvoeringen permanent negeerde. Deze workflows bleven onbeperkt in de status RUNNING, terwijl hun taken nooit werden uitgevoerd. Echter, zowel de decider queue als de queue voor dat taaktype waren leeg. We observeerden dit bij het doden van een enkele Conductor-server, het isoleren van een server van Postgres of Redis, of het doden van Postgres. Noch wachten noch het herstarten van de nodes zorgde ervoor dat Conductor de verlaten workflows opnieuw uitvoerde.

Beschouw deze testrun, waarbij we tijdens de hoofdfase van 15 seconden server n4 één keer doodden en vervolgens herstartten. Dit zorgde ervoor dat workflow 70ee12a3-7bd8-11f1-8029-00163e983f22 werd verlaten. Zelfs uren na de test lijstte het cluster die workflow op in status RUNNING, maar geen enkele worker probeerde ooit de taak ervan uit te voeren.

Wanneer een workflow wordt gestart, schrijft Conductor eerst een workflow-record naar Postgres, en plaatst vervolgens de workflow in de decider queue. Als er een fout optreedt tijdens een van die schrijfacties, kan de workflow succesvol naar Postgres zijn geschreven, maar ontbreken in de queue; hij zou bijgevolg nooit worden uitgevoerd. Verrassend genoeg vertrouwt het sweeper-proces, dat periodiek lopende workflows monitort, puur op de decider queue en niet op de database. Dit betekende dat Conductor nooit herstelde van deze partiële schrijfacties. In versie 5.5.0 heeft Orkes een achtergrondproces toegevoegd dat verlaten uitvoeringen opnieuw in de queue plaatst (#5).

3.8 Lost Workflows (#7)

In versies 5.3.2, 5.4.0 en 5.5.0-rc1 konden netwerkpartities tussen een Conductor-server en Postgres, of het doden van Postgres, er zelden voor zorgen dat Conductor het bestaan van workflow-uitvoeringen vergat die eerder zichtbaar waren voor clients. Aanroepen naar de getWorkflow()-methode van de Java SDK retourneerden null, en HTTP-verzoeken naar /api/workflow/<id> retourneerden een 404-fout. Deze toestand hield onbeperkt aan; verloren workflows waren niet herstelbaar.

Neem bijvoorbeeld deze test van versie 5.4.0 met Postgres als DB en decider queue, en Redis als normale queue. Workflow 0c426286-80e9-11f1-bb95-00163eb95b8c werd 239 seconden in de test ontdekt via een aanroep naar getRunningWorkflow(). Conductor hield vol dat deze workflow in status RUNNING was tot 313 seconden. Van 313 seconden tot het einde van de test op 677 seconden retourneerde elke aanroep naar getWorkflow voor dat ID null.

Deze workflow-uitvoering was gecommit naar Postgres, maar de server was niet in staat hem aan de decider queue toe te voegen—misschien door een netwerkpartitie. Gezien deze fout besloot de server vervolgens zijn schrijfactie naar Postgres ongedaan te maken door het workflow-record te verwijderen. In de tussenliggende 74 seconden was die gedoemde workflow zichtbaar voor clients. In versie 5.5.0 verwijdert Conductor uitvoeringen niet langer wanneer een stap faalt (#7).

3.9 Te koop: Voltooid, maar nooit uitgevoerd (#9)

Wanneer Postgres werd gedood, vonden we af en toe workflows in status COMPLETED waarvan de taken nooit daadwerkelijk waren uitgevoerd. In deze test van versie 5.5.0-rc2 waren drie workflows voltooid maar nooit uitgevoerd. Aan het einde van de test zag workflow 774977a0-8324-11f1-911d-00163e983f22 er als volgt uit:

{
  "tasks" : [{
    "pollCount" : 1,
    "reasonForIncompletion" : "responseTimeout: 10 exceeded for the taskId: 77499eb5-8324-11f1-911d-00163e983f22 with Task Definition: greet",
    "retried" : true,
    "taskDefinition" : {
      "retryDelaySeconds" : 5,
      "rateLimitFrequencyInSeconds" : 1,
      "responseTimeoutSeconds" : 10,
      "retryCount" : 2147483647,
      "timeoutPolicy" : "TIME_OUT_WF",
      ...
    },
    "retryCount" : 0,
    "status" : "TIMED_OUT",
    "executed" : false,
    "workerId" : "n7",
    ...
  }],
  "status" : "COMPLETED",
  ...
}

Hoewel de enige taak van deze workflow zogenaamd was toegewezen aan worker n7, logde noch n7 noch enige andere worker deze: het lijkt erop dat Worker.execute nooit werd aangeroepen voor deze taak. De taak eindigde in status TIMEDOUT, en het retry-beleid was TIMEOUTWF, wat volgens de documentatie over foutafhandeling van Conductor ertoe had moeten leiden dat de workflow ook de status TIMEDOUT kreeg. In plaats daarvan werd de workflow gemarkeerd als COMPLETED, ondanks dat deze nooit was uitgevoerd.

Deze workflow werd beïnvloed door het eerder besproken probleem van taagstagnatie (#2): worker n7 pollde de decider queue en Conductor geloofde dat n7 de taak zou uitvoeren, maar (misschien door een netwerkfout) begon n7 nooit met de uitvoering. De sweeper-taak op server n4 markeerde de taak vervolgens als TIMED_OUT en probeerde een retry in te plannen. Dat retry-proces voert twee aparte schrijfacties uit naar Postgres: één die een nieuw taakrecord invoegt, en een tweede die de oorspronkelijke taakrij als superseded markeert. Postgres faalde tussen deze twee schrijfacties, maar Conductor negeerde die fout en markeerde de oorspronkelijke taak als superseded zonder een nieuw taakrecord. Dit verklaart waarom de taak nooit opnieuw werd geprobeerd, ook al zei de taakdefinitie dat dit wel moest gebeuren. Het sweeper-proces van server n3 ontdekte vervolgens de superseded taak en concludeerde onterecht dat, aangezien alle taken een terminale status hadden, de workflow COMPLETED was.

Een databasetransactie rond deze twee schrijfacties zou dit probleem hebben voorkomen (#9). Echter, de twee schrijfacties kwamen uit verschillende lagen van de code, en het omhullen van de twee in een transactie zou aanzienlijke wijzigingen hebben vereist. In versie 5.5.6 negeert Conductor een mislukte schrijfactie van een retry-taakrecord niet langer.

Samenvatting van resultaten

NrSamenvattingVereiste gebeurtenisOpgelost in
#1Onverwacht gelijktijdige taakuitvoeringGeen5.5.6
#2Taken stagneren bij de meeste foutenPauze, crash, of partitieZo ontworpen
#3Null workflow-ID'sRedis crashNiet opgelost
#4startWorkflow retourneert syntaxfoutGeen5.5.6
#8startWorkflow retourneert duplicate key foutPostgres crash en pauze5.5.6
#6Aborted readServer crash, of Redis/Postgres crashNiet opgelost
#5Verlaten workflowsServer of Postgres crash, of partitie5.5.0
#7Ontbrekende workflowsPostgres crash of partitie5.5.0
#9Nooit uitgevoerde workflows gemarkeerd als voltooidPostgres crash en partitie5.5.6

4 Discussie

Onze tests van Orkes Conductor wezen uit dat taken vaker gelijktijdig werden uitgevoerd dan nodig (#1), en dat de uitvoering aanzienlijk kon worden vertraagd door fouten in een enkele node (#2). We vonden ook vier kleine fouten in startWorkflow (#3, #4, #6, #8) en drie ernstige veiligheidsfouten waarbij bevestigde workflows nooit werden uitgevoerd (#5), volledig verdwenen (#7), of werden gemarkeerd als COMPLETED ondanks dat ze nooit waren gedraaid (#9). Aangezien we veel van deze problemen konden reproduceren met fouten in een enkel proces, of in sommige gevallen konden observeren in gezonde clusters, is het denkbaar dat productiegebruikers van Orkes Conductor hiervan zijn getroffen.

Conductor herstelde automatisch van taagstagnaties (#2) na de standaard timeout van één uur; gebruikers hoeven geen actie te ondernemen om te zorgen dat getroffen workflows worden voltooid. Problemen #3, #4 en #8 zorgden ervoor dat startWorkflow een fout of een null workflow-ID retourneerde. Het is waarschijnlijk dat client-code deze fouten opmerkt en deze adequaat opnieuw probeert of doorgeeft. Probleem #6 zorgde ervoor dat succesvolle aanroepen naar startWorkflow schijnbaar definitieve "failed to connect"-fouten retourneerden; als clients deze fouten automatisch opnieuw probeerden, kunnen ze onbewust meerdere kopieën van een enkele workflow hebben gemaakt. Bij afwezigheid van documentatie is het voor gebruikers het veiligst om ervan uit te gaan dat alle Conductor-fouten onbepaald (indefinite) zijn.

Voor verlaten workflows (#5) kunnen gebruikers mogelijk herstellen door hun Conductor-clusters te doorzoeken naar RUNNING workflows die al enige tijd niet zijn uitgevoerd, en deze handmatig opnieuw te starten. Evenzo kunnen gebruikers zoeken naar workflows die in status COMPLETED staan, maar waarvan de taken niet zijn voltooid (#9), en deze ook opnieuw starten. Ontbrekende workflows (#7) zijn mogelijk herstelbaar door een extern systeem te raadplegen waarvan bekend is dat het informatie heeft over workflow-uitvoeringen voordat ze naar Conductor werden verzonden—bijvoorbeeld een log die wordt bijgehouden door een upstream-systeem.

We konden testen tot versie 5.5.0-rc2, waarin fixes voor problemen #5 en #7 waren opgenomen. Orkes heeft vervolgens versie 5.5.6 uitgebracht met fixes voor #1, #4, #8 en #9. We raden gebruikers aan om te upgraden naar versie 5.5.6, aangezien dit alle drie de ernstige veiligheidsproblemen aanpakt die we hebben gevonden. Taagstagnaties (#2) waren consistent met het beloofde gedrag van Conductor en hoeven niet te worden aangepakt. Null workflow-ID's (#3) en een misleidend "failed to connect"-foutbericht (#6) worden nog onderzocht.

In overige opzichten leek Conductor te functioneren zoals verwacht. Workflow- en taakinputs en -outputs waren consistent met elkaar, taakoutputs bleven stabiel ondanks dat ze meerdere keren werden uitgevoerd, enzovoort. Zoals altijd hanteert Jepsen een experimentele benadering van veiligheidsverificatie: we kunnen de aanwezigheid van bugs bewijzen, maar niet hun afwezigheid. Hoewel we uitgebreide inspanningen leveren om problemen te vinden, kunnen we correctheid niet bewijzen.

4.1 Concurrency

Gebruikers moeten zich ervan bewust zijn dat binnen een enkele workflow een enkele taak gelijktijdig door meerdere worker-threads kan worden uitgevoerd (#1). In onze tests observeerden we dit gedrag elke paar minuten tot uren in gezonde clusters, hoewel het een relatief klein deel van de workflows betrof. Dit is een inherente eigenschap van elk gedistribueerd uitvoeringssysteem en kan niet worden voorkomen—hoewel de frequentie kan worden verminderd.

Zelfs als wederzijdse uitsluiting mogelijk zou zijn, zou dit de effecten van workers die worden herordend of met elkaar worden afgewisseld niet voorkomen. Aangezien die effecten via een asynchroon netwerk worden gerouteerd, kunnen ze willekeurig worden vertraagd of verloren gaan. Dit is waarom gedistribueerde systemen vaak fencing tokens of consensus gebruiken in plaats van wederzijdse uitsluiting. In elk workflow-systeem moeten gebruikers ervoor zorgen dat taken idempotent zijn en commuteren—niet alleen met andere pogingen van dezelfde taak, maar ook met andere taken en met taken die door failure workflows worden uitgevoerd.

Stel bijvoorbeeld een workflow voor die een vlucht boekt. Conductor start de book-flight taak, maar de worker time-out, waardoor Conductor de book-flight taak opnieuw probeert. Ook deze time-out, en Conductor start een failure workflow om de vlucht te annuleren. De failure workflow voert een taak uit die de vlucht annuleert, ontvangt een bevestiging van de vluchtservice dat de vlucht is geannuleerd, en termineert. Daarna komt een van de verzoeken die door de book-flight taak zijn verzonden aan bij de vluchtservice, en de vlucht wordt opnieuw geboekt—terwijl de workflow deze schijnbaar had geannuleerd.

De oplossing is om ervoor te zorgen dat de book-flight operatie en de cancel-flight operatie commuteren—dat wil zeggen, ze moeten hetzelfde effect hebben ongeacht de volgorde van uitvoering. Het is niet voldoende dat de vluchtservice een bestaande vlucht annuleert: hij moet ook elk toekomstig verzoek om die vlucht te boeken voorkomen. Downstream-services kunnen idempotency keys en logical clocks gebruiken om deze commutatieve semantiek te bieden.

Deze uitdagingen zijn niet uniek voor Conductor, maar gelden voor elk gedistribueerd orchestratiesysteem. Het oorspronkelijke Saga-paper was bedoeld voor single-node databasesystemen. Wanneer dit wordt gegeneraliseerd naar gedistribueerde systemen, ontstaan er nieuwe foutmodi.

4.2 Opslagveiligheid

Conductor is slechts zo veilig als de opslagsystemen waar het op vertrouwt. Conductor 5.3.2 vereiste dat de systemen die werden gebruikt voor conductor.db.type en conductor.queue.type bevestigde schrijfacties niet verloren. In 5.4.0 kon het systeem dat werd gebruikt voor conductor.queue.type theoretisch bevestigde schrijfacties verliezen zonder de veiligheid van Conductor aan te tasten, zolang conductor.queue.decider-queue.type dat niet deed. Dit was niet helemaal waar (#7), maar het kernidee blijft overeind.

We hebben zowel single-node Redis als Redis Cluster-implementaties getest en ontdekt dat wanneer Redis werd gebruikt voor zowel de primaire DB als de queue in 5.3.2, Conductor bevestigde workflow-uitvoeringen kon verliezen wanneer Redis-nodes werden gedood of van elkaar werden gepartitioneerd. We hebben geen Postgres-dataverlies getest, maar het is redelijk om aan te nemen dat soortgelijke effecten optreden. De vraag voor Orkes en gebruikers die Conductor zelf implementeren is: hoe veilig zijn de gebruikte Redis- en Postgres-systemen?

Een enkele Redis-instantie is natuurlijk een single point of failure: hij kan gegevens verliezen als de schijven van de node falen, of als hij is geconfigureerd met een instelling die zwakker is dan appendfsync always en de node stroomverlies lijdt. Redis Cluster en Redis Sentinel kunnen beide bevestigde schrijfacties verliezen bij netwerkpartities. Beheerde Redis-compatibele services variëren in hun veiligheidsgaranties. ElastiCache Valkey en ElastiCache Redis OSS waarschuwen beide voor schrijfverlies, maar Amazon stelt dat dit niet zou moeten gebeuren met --durability sync. Google Cloud's Memorystore voor Redis kan ook bevestigde schrijfacties verliezen, evenals Azure Managed Redis met persistentie en high availability.

Single-node Postgres-implementaties zijn op soortige wijze kwetsbaar voor catastrofale node-fouten, zoals brand of corruptie van het bestandssysteem. Sommige Postgres-replicatieschema's zijn onveilig: zoals Bin Wang demonstreerde, kunnen Patroni-clusters in sommige situaties gecommitteerde schrijfacties verliezen, zelfs met synchronousmodestrict. Citus Cloud claimt dat "u geen gegevens zult verliezen", maar lijkt te vertrouwen op een asynchroon WAL-opslagsysteem dat tot 60 seconden of 16 MB aan schrijfacties kan verliezen. De documentatie van Google's Cloud SQL voor PostgreSQL waarschuwt voor de mogelijkheid van split-brain en dataverlies in sommige configuraties. Er zijn echter verschillende gedistribueerde databases die Postgres-compatibele interfaces bieden met sterkere veiligheidsgaranties, zoals CockroachDB.

Kortom, de ervaring kan per situatie verschillen. Hoewel we alleen de proprietary Orkes Conductor hebben getest, is het waarschijnlijk dat Conductor OSS op soortige wijze afhankelijk is van de veiligheid van zijn onderliggende opslagservices. Om verloren of verlaten workflow-uitvoeringen te voorkomen, raden we gebruikers aan hun keuze voor conductor.db.type en conductor.queue.type en de veiligheid van die respectievelijke systemen zorgvuldig te evalueren.

4.3 Toekomstig werk

Hoewel deze tests zijn uitgevoerd op Orkes Conductor, gebruikten ze de standaard Conductor Java-client. Met een kleine inspanning zou de testsuite ook het gedrag van Conductor OSS kunnen meten. We richtten ons op conductor.db.type=postgres en conductor.queue.type=redis_standalone, maar Conductor ondersteunt een verscheidenheid aan opslagsystemen. Toekomstig onderzoek zou het gedrag met Cassandra, MySQL en SQLite kunnen verkennen.

Onze tests evalueerden alleen workflows met een enkele taak met behulp van onze custom worker. We hebben nog geen multi-stap workflows verkend, workflows die gebruikmaken van de ingebouwde taken of control-flow taken van Conductor, of failure workflows. Al deze zaken zouden nuttige wegen zijn voor verder testen. Hoewel we hebben getest met procespauzes, crashes en netwerkpartities, hebben we nog geen hoge netwerklatentie of clock skew verkend.

***

Voetnoten:

  1. Er zijn twee niet-gerelateerde orchestratiesystemen met soortgelijke namen: één van Microsoft, ook genaamd Conductor, en één genaamd Postgres Conductor. Er is ook een niet-gerelateerde AI-marketingservice genaamd Conductor.
  2. Hoewel operaties binnen Conductor slechts één succesvolle taakuitvoering zouden moeten zien, kunnen taken zichtbare neveneffecten hebben op systemen buiten Conductor. Goed functionerende taken zouden daarom idempotent en deterministisch moeten zijn.
  3. We ontdekten dit probleem net toen ons werk aan Conductor ten einde liep; helaas zijn we niet in staat geweest om met andere versies te testen of de foutmodi die dit kunnen induceren nauwer te bepalen.