Gå til innhold

Bakgrunnsjobber⚓︎

Oversikt⚓︎

Alt tungt arbeid i Kai — å oppdage nye saker, hente og ekstrahere dokumenter, kjøre regelmotoren — skjer i Enova.Kai.Worker, ikke i API-et. Saksbehandleren venter aldri på det; webflaten leser resultatene fra Postgres etterpå.

Scheduleren er TickerQ 10.4.0, Postgres-backet via TickerQ.EntityFrameworkCore mot KaiDbContext (ADR-012). Det finnes ingen kø-tjeneste ved siden av: køen er TickerQ-tabellene i ticker-skjemaet, i samme database som saksdataene.

TickerQ kjenner to slags arbeid, og Kai bruker begge:

  • CronTicker — tidsstyrte inngangspunkter uten payload. Registreres med [TickerFunction(functionName, cronExpression, maxConcurrency)] og seedes til ticker.CronTickers ved oppstart.
  • TimeTicker — én rad per arbeidsenhet, med en typet payload (CaseWorkPayload eller DocumentWorkPayload). Legges i køen via IPipelineWorkQueue og plukkes av jobben som bærer samme funksjonsnavn.

Saksbehandlingen er derfor ikke én stor jobb, men en kjede av steg som køer neste steg til seg selv. Stegnavnene er konstanter i PipelineFunctions: FetchDocuments, ExtractDocument og RunCaseWorkflow.

Saksbehandlingspipelinen⚓︎

flowchart TD
    Cron([SakSeleksjon<br/>cron]) --> Sel[Velg kandidatsaker<br/>fra Sak-replikaen]
    Sel -->|TimeTicker: CaseWorkPayload| Fetch
    Manual([POST /internal/pipeline/reanalyze<br/>fra kai-api]) -->|Manual=true| Fetch

    Fetch[FetchDocuments<br/>én ticker per sak] --> Websak[(Websak → Blob<br/>originaler + manifest)]
    Fetch -->|TimeTicker per dokument:<br/>DocumentWorkPayload| Extract

    Extract[ExtractDocument<br/>én ticker per dokument] --> CU[(Content Understanding<br/>facts-blob)]
    Extract --> FanIn{Alle dokumenter<br/>terminale?}
    FanIn -->|nei| Vent([Venter på søsken-tickerne])
    FanIn -->|ja, men ingen lyktes| Perm([Bokføres permanent feilet<br/>— ingen regelkjøring])
    FanIn -->|ja, én vinner tar fan-in| Eval

    Bulk([BulkReevaluation<br/>manuelt trigget]) --> Eval
    Eval[RunCaseWorkflow<br/>regelmotoren] --> Db[(Postgres<br/>Regelresultat)]

SakSeleksjon — cron-inngangen⚓︎

SelectionJob henter aktive virkemiddel-id-er fra databasen (IActiveVirkemiddelProvider) og spør Sak-replikaen, ikke Mimir, om kandidater med kvalifiserende ERS-status (ISakSeleksjonKilde). Saker som allerede er behandlet filtreres bort mot polling_processed_cases, resten kuttes til Kai:SakSeleksjon:MaxCasesPerTick.

For hver gjenstående sak claimes Discovery-steget (TryBeginStepAsync) før FetchDocuments køes. Rekkefølgen — enqueue, så bokfør Succeeded — er bevisst: dør prosessen imellom, står claimet igjen som Pending og plukkes opp av reconciliation.

FetchDocuments — én ticker per sak⚓︎

FetchDocumentsJob slår opp saken (ISakOppslag), finner analyzer-rot-id-en for virkemiddelet i analyzers.lock.json, lister og lagrer originaldokumentene fra Websak (IWebsakDocumentLister.ListAndStoreOriginalsAsync), oppretter dokumentmanifestet (ISakDokumentEkstraksjonStore.CreateManifestAsync) og fan-out'er én ExtractDocument-ticker per dokument.

To utfall er ikke feil:

  • Mangler virkemiddelet i lockfilen — deploy-/konfigmangel, ikke saksdatafeil. Ingen tilstand skrives, så saken re-oppdages av SakSeleksjon når analyzeren er deployet.
  • Websak svarer 403 / §13 (WebsakCaseExcludedException) — steget markeres Excluded. Ingen retry, ingen feilteller, og saken re-selekteres ikke.

ExtractDocument — én ticker per dokument⚓︎

ExtractDocumentJob claimer manifest-raden, og hopper over Content Understanding dersom facts-bloben allerede finnes (med mindre payloaden bærer TvingReekstraksjon). Ellers kjøres CU bak ExtractConcurrencyGate med en hard per-dokument-timeout (Kai:Extraction:DocumentTimeoutSeconds). Tom CU-respons behandles som feil, ikke som suksess. Etterpå leses Kind (CU-kategorisettet) tilbake fra bloben — feiler det, blir dokumentet likevel Succeeded.

Til slutt fan-in: er alle dokumentene på saken terminale, tar nøyaktig én ticker fullføringen (TryClaimFanInAsync) og køer RunCaseWorkflow. Taperne no-op'er, så en tvunget reanalyse med N dokumenter gir én evaluering, ikke N.

Lyktes ingen dokumenter, køes ingen evaluering. Saken bokføres permanent feilet i stedet — uten fakta ville regelmotoren produsert et villedende «ikke godkjent» som ser ut som en avgjørelse, men egentlig maskerer en ekstraksjonsfeil.

RunCaseWorkflow — regelkjøringen⚓︎

RunCaseWorkflowJob bygger RunCaseWorkflowCommand (find-or-create på saken) og sender den via Mediator, bak EvaluationConcurrencyGate og en timeout på Kai:Evaluation:TimeoutSeconds. Bulk-kjøringer bruker en egen gate slik at de ikke konkurrerer med vanlig polling. Resultatet er Regelresultat-rader i Postgres.

Idempotens, retry og feilbudsjett⚓︎

Hvert steg er beskyttet av en claim i polling_processed_cases ((saksnummer, step), med attempt-teller). TryBeginStepAsync returnerer false når steget allerede er i flight, ferdig eller permanent feilet — kjøringen no-op'er da stille. Flaggene Manual og Reclaimed på payloaden forcer claimet, slik at en bevisst re-kjøring ikke dedupes bort av historikken.

Retry-transporten er TickerQ: hver TimeTicker settes opp med Retries = Kai:Polling:MaxFailedAttempts og backoff-trappa Kai:Polling:RetryIntervalsSeconds (30 s, 2 min, 5 min, 15 min, 30 min). Men det er store-breakeren som avgjør når man skal gi opp: MarkStepFailedAsync promoterer til PermanentlyFailed når attempt når taket. Konsekvensen i jobbkoden er konsekvent:

Utfall Hva jobben gjør
Transient feil Rethrow → TickerQ backoff-retry
PermanentlyFailed Svelges → tickeren «lykkes», ingen flere forsøk
Kooperativ shutdown Claimet frigis budsjett-nøytralt, så rethrow
Payload er null Rethrow — TickerQ fikk ikke hentet payloaden, det er en infrastrukturfeil

Samtidighet — to sett grenser⚓︎

Grensene ligger i to lag, og de verner forskjellige ting:

Lag Konfigurert av Verner
TickerQ-dispatch, per funksjonsnavn Kai:PipelineDispatch:* Postgres. Grensen tas før payloaden hentes; uten den gikk hele den forfalte batchen i flight (målt: 197 samtidige dispatcher → 53300 sorry, too many clients already). Summen pluss reserverte connections må få plass i workerens pool og håndheves ved oppstart.
Prosess-vid gate inne i jobbkroppen Kai:FetchDocuments:MaxConcurrency, Kai:Extraction:MaxConcurrency, Kai:Evaluation:MaxConcurrency, Kai:BulkReeval:MaxConcurrency Den eksterne ressursen: Websak, Content Understanding, regelmotorens LLM-kall.

Selve LLM-kallene har i tillegg sin egen grense på provideren (Kai:Providers:AzureAiFoundry:MaxConcurrentCompletions), fordi én sak fan-out'er til flere regelevalueringer — et sakstak oversettes ikke til et kalltak.

Reconciliation⚓︎

PipelineReconciliationJob kjører på egen cron (Kai:Extraction:ReconciliationCron, hvert 10. minutt) og retter opp det en død prosess etterlater seg. Alt den ser på er eldre enn Kai:Extraction:StaleAfterSeconds:

Fase Retter opp
0 Forlatte Pending-claims på Discovery/FetchDocuments — re-driver FetchDocuments med Reclaimed.
1 Stale, ikke-terminale manifest-rader — re-køer ExtractDocument med Forced (uten å betale CU på nytt).
2 Saker der alle dokumenter er terminale, men Evaluation aldri ble køet — backstop for tapt enqueue.
3 Forlatte Pending-claims på Evaluation selv — re-driver med Reclaimed.
4 Forlatte InProgress-tickere med stemplet lås-holder — kanselleres, ellers blokkerer de manuell reanalyse for godt.

Ved oppstart gjør OrphanedTickerLockReclaimHostedService det samme én gang for tickere som ble stående låst da forrige worker døde: rader uten lås-holder settes tilbake til Idle, og rader som har stått InProgress lenger enn Kai:TickerReclaim:StaleInProgressAfterMinutes kanselleres. Den kjører etter databasemigrasjonen og før TickerQ starter.

ReplikaSync — daglig synk fra Mimir⚓︎

SyncJob (funksjon ReplikaSync) synker ERS- og masterdata fra Mimirs gull-lag til den lokale replikaen: virkemidler, ERS-statuser, saker, vurderinger, søknadskostnader og — for yrkesbygg — energiattest-koblinger. Jobben er freshness-styrt: er ikke gull-dumpen nyere enn forrige synk, hopper den over. Gull bygges én gang i døgnet, så hyppigere kjøring finner ingenting nytt (se Kildesystemer).

Vedlikeholds- og backfill-jobber⚓︎

Funksjon Cron Hva den gjør
TickerRetention Aktiv, 03:30 daglig Sletter terminale kjøringer eldre enn Kai:TickerRetention:RetentionDays fra ticker.TimeTickers og ticker.CronTickerOccurrences. Rører aldri køede eller pågående tickere.
BulkReevaluation Dvalende Køer Evaluation for alle saker med ekstraherte fakta. Hopper over CU.
DokumentnavnBackfill Dvalende Friskner opp dokumentenes visningsnavn via ett Websak-oppslag per sak. Ingen CU, ingen re-evaluering. Maks MaxCasesPerRun saker per kjøring.
DokumentKindBackfill Dvalende Bakfyller Kind på manifest-rader ekstrahert før feltet fantes, ved å lese eksisterende facts-blober.

Dvalende betyr at cron-raden finnes, men at TickerQ-seederen i Program.cs setter IsEnabled = false ved hver oppstart. De fyrer aldri av seg selv og startes manuelt fra dashboardet. (Cron-uttrykket deres er 29. februar — gyldig for NCrontab, men i praksis aldri; et ugyldig uttrykk som 31. februar ville krasjet scheduler-loopen.)

Manuell kjøring⚓︎

To veier inn utenom cron:

  • Reanalyse fra webflaten. kai-api kaller POST /internal/pipeline/reanalyze på workeren, som køer FetchDocuments med Manual = true. Enqueuen er idempotent: ligger det allerede en manuell kjøring for saken i kø eller under arbeid, svares 409 uten ny ticker. Endepunktet ligger bak Easy Auth i test og prod, og er kun localhost-eksponert lokalt.
  • Dashboardet. Enhver cron-funksjon kan trigges for hånd, inkludert de dvalende.

Dashboard⚓︎

TickerQ-dashboardet kjører i worker-prosessen på /tickerq; rot-URL-en redirigerer dit. Tilgangen styres på plattformnivå, ikke i appen: App Service Easy Auth med Entra ID, begrenset til Administrator-gruppen. Lokalt (Aspire) er dashboardet åpent på localhost. /health og /alive er unntatt autentisering.

Beskrivelsene som vises per cron-funksjon settes av TickerQ-seederen i Program.cs — [TickerFunction] har ingen description-parameter, så de auto-seedede radene ville ellers stått uten tekst.

Dashboardets graf-endepunkter går mot en egen dekoratør som gjør aggregeringene som ekte SQL GROUP BY. TickerQs egen implementasjon laster hele ticker-tabellene i minnet, og har tatt ned workeren. Feiler oppkoblingen av dekoratøren, logges det og TickerQs opprinnelige registrering står — et tregt dashboard er bedre enn en død worker.

Konfigurasjon⚓︎

Verdiene under er de som faktisk står i src/Enova.Kai.Worker/appsettings.json; nøkler uten oppføring der bruker klasse-defaulten. Ingen av dem overstyres per miljø i dag.

Nøkkel Verdi Beskrivelse
Kai:SakSeleksjon:CronExpression 0 0 * * * * 6-felts cron for SakSeleksjon (hver time)
Kai:SakSeleksjon:ErsStatuses [0, 1, 5] ERS-statuser som kvalifiserer for automatisk analyse
Kai:SakSeleksjon:MaxCasesPerTick 25 Hard cap per tick
Kai:ReplikaSync:CronExpression 0 0 6 * * * Daglig synk fra Mimir gull, 06:00
Kai:Extraction:ReconciliationCron 0 */10 * * * * PipelineReconciliation
Kai:Extraction:StaleAfterSeconds 3600 Når en pågående kjøring regnes som forlatt
Kai:Extraction:DocumentTimeoutSeconds 900 Hard timeout for hele CU-kallet per dokument
Kai:Extraction:ReconciliationBatchSize 100 Rader per reconciliation-fase
Kai:Extraction:MaxConcurrency 4 Prosess-vid grense mot Content Understanding
Kai:FetchDocuments:MaxConcurrency 4 Prosess-vid grense mot Websak
Kai:Evaluation:TimeoutSeconds 600 Timeout per saksevaluering
Kai:Evaluation:MaxConcurrency 4 (default) Prosess-vid grense for polling-evaluering
Kai:BulkReeval:MaxConcurrency 2 Egen grense for bulk re-evaluering
Kai:PipelineDispatch:{FetchDocuments,ExtractDocument,Evaluation} 4 / 4 / 4 TickerQ-dispatch-bredde per funksjon
Kai:TickerRetention:CronExpression 0 30 3 * * * Opprydding, 03:30 daglig
Kai:TickerRetention:RetentionDays 14 Beholdes så lenge
Kai:TickerRetention:BatchSize 1000 Rader per slettebatch
Kai:DokumentnavnBackfill:MaxCasesPerRun 50 Saker per manuell kjøring
Kai:Polling:MaxFailedAttempts 5 (default) Feilbudsjett per steg før PermanentlyFailed
Kai:Polling:RetryIntervalsSeconds [30, 120, 300, 900, 1800] (default) TickerQ-backoff per forsøk
Kai:TickerReclaim:StaleInProgressAfterMinutes 30 (default) Når en InProgress-ticker regnes som forlatt