Strukturert samtidighet er et konsept som omfavnes av Kotlin sin coroutines implementasjon. Tanken er at alle prosesser som eksekveres som coroutines flettes sammen i et hierarki.

En mer konkret visualisering er f.eks en trestruktur. Under en rotnode kan man teoretrisk ha flere nivåer av barnenoder som er relaterte. Således vil en oppstått feil på de dypeste nivåene drepe kjørende prosess og propagere feilen hele veien opp til rotnoden. Og det er nettopp dette som er kjernen i strukturert samtidighet.

Trenger du en oppfrisker på hva coroutines faktisk er, hvordan det virker og konkrete eksempler på kjørende kode har jeg skrevet en introduksjon til dette tidligere.

SuspendApp

Hva er SuspendApp og hvorfor skal jeg bry meg om det ? SuspendApp er et bibliotek som beleilig nok implementerer strukturert samtidighet og er fritt tilgjengelig.

Jeg har ved tidligere anledninger snakket varmt om Arrow og her har vi nok et prosjekt fra gjengen bak dette.

Kode kode kode

And here we go!

fun main() = SuspendApp {
    result {
        resourceScope {
            val registry = metricsRegistry()

            server(Netty, port = 8080, preWait = 5.seconds) { configure(registry) }

            awaitCancellation()
        }
    }
        .onFailure { error ->
            when (error) {
                is CancellationException -> {} // expected behaviour - normal shutdown
                else -> logError(error)
            }
        }
}

private fun logError(t: Throwable) = log.error { "Shutdown app due to: ${t.stackTraceToString()}" }

La oss dissekere denne godbiten her.

ResourceScope

SuspendApp-lambdaen holder på all innmaten vi trenger. Vi kan begynne med de åpenbare fremmedordene. Et resourceScope sørger for å strukturere alle avhengighetene våre, eller ressursene om du vil.

Avhengigheter både installeres og termineres i et resourceScope. Scopet sørger også for at relasjoner mellom avhengigheter ivaretaes og installeres i henhold til rekkefølgen de er definert. Rekkefølgen reverseres når scopet termineres. Vi skal se litt mer på the nitty gritty stuff her senere.

I koden ovenfor har vi én enkelt avhengighet, et metricsRegistry. Vi ønsker at applikasjonen vår spytter ut metrikker fortløpende og har derfor aktivert Prometheus.

La oss si vi har en Resource- fil som holder på alle ressursene våre og denne definerer funksjonen:

suspend fun ResourceScope.metricsRegistry(): PrometheusMeterRegistry =
    install({ PrometheusMeterRegistry((DEFAULT)) }) { p, _: ExitCase -> p.close() }

Prometheus-metrikker er nå definert som en egen ressurs og håndtert gjennom livssyklusen i resourceScope.

awaitCancellation() gjør nettopp det navnet tilsier, den lytter etter CancellationException. Et internt terminerings-signal fra coroutines-maskineriet. CancellationException uten en cause er forventet oppførsel. F.eks når en applikasjon re-startes.

Men vent litt, hoppet vi ikke bukk over server() ? Joda, det var med overlegg. Dette er en modul i SuspendApp. Mer spesifikt er dette en intern ressurs som wrapper en Ktor-server. Hva er poenget med det ? Ah, nettopp, ktor håndteres på lik linje med andre avhengigheter innenfor rammene av et resourceScope.

Result

Jeg ser at resourceScope omkranses av en result - lambda og det foregår noe form for feilhåndtering, tenker du kanskje. Jepp, det er riktig og fortjener en utdypning.

Result er en Kotlin type som enten består en av success eller en failure. Lambdaen er kun en wrapper som sørger for at result sluses gjennom Arrow sitt eget feilhåndteringssystem og passer som hånd i hanske med den flytende koden vi allerede har.

Fair enough, men hvorfor gjør vi egentlig dette ? Var det ikke sånn at strukturert samtidighet innebærer at feil blir propagert og logget ? Det er helt riktig, men hvis vi ønsker å logge noe vi ikke kan håndtere og aggregere dette i et 3-partsssytem ala Kibana, så vil vi jo gjerne unngå å dobbeltlogge dette.

Hmmmmm, dobbeltlogge ? Ja, dette eksemplet er ypperlig for å illustrere hvor kraftig strukturert samtidighet faltisk er.

SuspendApp konfigurerer og setter opp en coroutine-kontekst for oss og denne holder da på rotnoden i coroutines-hirerakiet. Således vil alle feil i barnenoder propageres hele veien opp, med mindre vi gjør noe aktivt for å bryte kjeden.

Siden vi selv ønsker å logge faktiske feil, feil vi ikke har noen strategi for å håndtere, vil det bare være støy og propagere feilen hele veien til toppen. Da vil exception handleren til rotnoden printe denne til standard error.

Hvis vi pirker litt i onFailure() ser vi at denne er veldig eksplisitt og tilsynelatende ikke gjør noe som helst når vi får et CancellationExcepion. Kan det stemme da ? Er vi ikke tilbake til dobbeltlogging ?

Neida, det er litt mer sofistikert enn som så. CancellationException er som nevnt tidligere corutines sitt eget interne signal for å terminere prosesser. Essensen er at når det faktisk er en underliggende feil vil denne bli pakket inn i form av en cause.

Result - lamdaen vil automatisk sørge for utpakking hvis det finnes en cause ellers gjør den ingenting. Propageres et CancellationException uten en cause videre til rotnoden vil denne ikke printe noe som helst til standard error. Og det gir jo fullstendig mening. Dette er forventet oppførsel. Exception handleren alle coroutines har er jo tross alt en uncaught exception handler.

Kode kode komplett

Skal vi se litt på hvordan koden vår ser ut når vi har en definert rekkefølge på avhengigheter ?

fun main() = SuspendApp {
    result {
        resourceScope {
            val hikari = hikari(...)
            val database = database(hikari)

            val registry = metricsRegistry()

            with(database) {
                server(Netty, port = 8080, preWait = 5.seconds) { configure(registry) }
            }
            
            awaitCancellation()
        }
    }
        .onFailure { error ->
            when (error) {
                is CancellationException -> {} // expected behaviour - normal shutdown
                else -> logError(error)
            }
        }
}

private fun logError(t: Throwable) = log.error { "Shutdown app due to: ${t.stackTraceToString()}" }

Et klassisk oppsett. Connection pooling via Hikari og en valgfri database. Vi ønsker selvsagt at connection poolen initialiseres før databasen da denne er en direkte avhengighet.

På denne måten kan vi stacke avhengigheter i et resourceScope og rekkefølgen angir hvordan disse initialiseres. Når applikasjonen terminerer reverseres rekkefølgen. I dette scenarioet vil databasen bli tatt ned først, deretter connection poolen osv.

Hva er så oppsiden med alt dette ? Siden alle avhengigheter er håndtert som ressurser i et scope vil de bli inititialisert og termintert på en ryddig måte. Ingen løse ender som henger igjen. Et scenario jeg har dratt nytte av dette i er når det f.eks har skjedd en feil i tilkoblingen mot databasen. Da termineres hele applikasjonen og re-startes inntil feilen har blitt løst. Siden det er lite vi kan gjøre i et slikt scenario, foruten å vente til problemet blir løst, kan vi betrakte applikasjonen som selvhelbredende.

I motsatt tilefelle, som jeg har reell erfaring med i produksjon, er det veldig fort gjort at applikasjonen blir stående og kjøre selv om en prosess lengre ned i hierariket feiler. Dette avhengeger selvfølgelig av hvordan hirerakiet er konfiguert.

Tilsynelatende kan det virke som alt er normalt. Og det er jo litt uheldig siden du absolutt ikke har noen garanti for at den henter seg inn igjen om feilen blir løst utenfor applikasjonen.

Skykompatibilitet

Det ville vært svært begrensende hvis bruken av SuspendApp ikke tar høyde for at appliklasjonene dine snurrer rundt i en skyløsning. Les:

“Dette bør fungere i et Kubernetes-cluster uten at jeg må hacke til noe selv”.

Og javisst, det gjør jo det.

Graceful shutdown er en ting i Kubernetes-verdenen og eksempelvis suspendapp-ktor sørger for at applikasjonen din ikke rives ned umiddelbart når pod’en mottar SIGTERM. Dette gjøres fordi Kubernetes internt trenger noe tid på å ta ned alt av nettverkskonfigurasjoner før all trafikk strupes.

Default har suspendapp-ktor en grace-periode på 30 sekunder før den terminerer serveren. Dette kan man selvsagt overstyre om ønskelig.

End game

Go. Use. It

SuspendApp er et glimrende lite bibliotek som har en veldig definert funksjonsflate. Jeg har selv en god del erfaring med å bruke dette i produksjon og det glir sømnløst inn med Arrow, corutines og 3-partsprodukter (via moduler).