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 tilticker.CronTickersved oppstart. - TimeTicker — én rad per arbeidsenhet, med en typet payload
(
CaseWorkPayloadellerDocumentWorkPayload). Legges i køen viaIPipelineWorkQueueog 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 markeresExcluded. 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/reanalyzepå workeren, som køerFetchDocumentsmedManual = true. Enqueuen er idempotent: ligger det allerede en manuell kjøring for saken i kø eller under arbeid, svares409uten 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 |