Compare commits

...

2 commits

Author SHA1 Message Date
29c0fb439d Version 1.5.2
Some checks failed
deploy community / deploy (push) Successful in 20s
deploy hub / deploy (push) Successful in 22s
CI / Build og unit tests (push) Successful in 12s
CI / MSI-installer (push) Successful in 14s
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 09:43:05 +02:00
8db226d2b6 Dynamics AX 3.0: batch STATUS checked against customer data
Cascorp's BATCH table: every STATUS 4 row has an end time and no exception
code (finished), STATUS 1 rows have no end time and starts long passed
(waiting). So AX 3.0 uses the same order as AX 4.0+: 0 Tilbageholdt,
1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet. The old assumption (0 Venter ...
4 Tilbageholdt) treated finished jobs as held and waiting ones as running.

SQLI-AX-003 skips waiting jobs planned more than BatchAbandonedDays (30)
ago: the customer has 25 such rows from 2007-2018 that no batch server
serves; they are dead rows, not a live alarm.

Adds docs/dynamics-ax/verificer-ax30-batchstatus.sql (read-only) to repeat
the check at other AX 3.0 customers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 09:36:56 +02:00
15 changed files with 130 additions and 50 deletions

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product> <Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright> <Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. --> <!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.5.1</Version> <Version>1.5.2</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))"> <PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

@ -17,8 +17,8 @@
-- venter på næste gentagelse, men dets seneste kørsel i SQLInsights egen historik (insight.AxBatchRuns) endte med -- venter på næste gentagelse, men dets seneste kørsel i SQLInsights egen historik (insight.AxBatchRuns) endte med
-- fejl inden for grænsen. -- fejl inden for grænsen.
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet, -- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata) -- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 samme rækkefølge (1 og 4 kontrolleret mod kundedata 2026-10-03):
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database. -- 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet. Højst 25 fund pr. database.
-- --
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi): -- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchFailedLookbackHours = 24: fejl inden for så mange timer rapporteres. -- SQLInsight:DynamicsAx:BatchFailedLookbackHours = 24: fejl inden for så mange timer rapporteres.

View file

@ -19,8 +19,8 @@
-- BATCHJOBHISTORY i AX 2009+, ellers de kørsler, SQLInsight selv ser blive afsluttet. Hænger jobbet (SQLI-AX-004), -- BATCHJOBHISTORY i AX 2009+, ellers de kørsler, SQLInsight selv ser blive afsluttet. Hænger jobbet (SQLI-AX-004),
-- gives kun det fund. -- gives kun det fund.
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet, -- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata) -- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 samme rækkefølge (1 og 4 kontrolleret mod kundedata 2026-10-03):
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database. -- 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet. Højst 25 fund pr. database.
-- --
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi): -- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchLongRunningFactor = 2.0: kørt mere end så mange gange jobbets 90 %-fraktil -- SQLInsight:DynamicsAx:BatchLongRunningFactor = 2.0: kørt mere end så mange gange jobbets 90 %-fraktil

View file

@ -19,12 +19,14 @@
-- sommertidsregler, der gjaldt på datoen. AX' tomme dato (1900-01-01) er "ingen". Job med status Tilbageholdt er -- sommertidsregler, der gjaldt på datoen. AX' tomme dato (1900-01-01) er "ingen". Job med status Tilbageholdt er
-- ikke forsinkede. De mest forsinkede først. -- ikke forsinkede. De mest forsinkede først.
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet, -- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata) -- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 samme rækkefølge: 0 Tilbageholdt, 1 Venter,
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database. -- 2 Udfører, 3 Fejl, 4 Afsluttet (1 og 4 kontrolleret mod kundedata 2026-10-03). Højst 25 fund pr. database.
-- Job planlagt for mere end BatchAbandonedDays siden regnes som opgivne rækker og meldes ikke.
-- --
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi): -- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchLateMinutes = 30: forsinket mere end så mange minutter: Medium -- SQLInsight:DynamicsAx:BatchLateMinutes = 30: forsinket mere end så mange minutter: Medium
-- SQLInsight:DynamicsAx:BatchNeverStartedHours = 24: forsinket mere end så mange timer: High, "aldrig startet" -- SQLInsight:DynamicsAx:BatchNeverStartedHours = 24: forsinket mere end så mange timer: High, "aldrig startet"
-- SQLInsight:DynamicsAx:BatchAbandonedDays = 30: planlagt for mere end så mange dage siden: opgivet, ingen fund
-- --
-- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql): -- Rettigheder til agentens login (se db/permissions/grant-least-privilege.sql og grant-dynamics-ax-read.sql):
-- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger): -- Valgfri AX-læseadgang, kun SELECT (db/permissions/grant-dynamics-ax-read.sql; [bruger] = agentens databasebruger):

View file

@ -19,8 +19,8 @@
-- efter sin egen historik (insight.AxBatchRuns, mindst BatchHistoryMinimumRuns kørsler) normalt kører så længe -- efter sin egen historik (insight.AxBatchRuns, mindst BatchHistoryMinimumRuns kørsler) normalt kører så længe
-- (kørt <= faktor x 90 %-fraktil). -- (kørt <= faktor x 90 %-fraktil).
-- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet, -- STATUS oversættes af versionens adapter: AX 2009+ 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet,
-- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 (antaget, ikke verificeret mod kundedata) -- 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret; Axapta 3.0/4.0 samme rækkefølge (1 og 4 kontrolleret mod kundedata 2026-10-03):
-- 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Højst 25 fund pr. database. -- 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet. Højst 25 fund pr. database.
-- --
-- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi): -- Grænseværdier (konfigurationsnøgle i agentens appsettings.json og standardværdi):
-- SQLInsight:DynamicsAx:BatchStuckHours = 6: kørt mindst så mange timer -- SQLInsight:DynamicsAx:BatchStuckHours = 6: kørt mindst så mange timer

View file

@ -50,7 +50,7 @@ Den præcise SQL for hver regel står i [`SQL Scripts/`](../SQL%20Scripts/) (fx
|---|---|---| |---|---|---|
| SQLI-AX-001 | Batchjob er endt med fejl (nu, eller seneste kørsel af et gentaget job) | `BatchFailedLookbackHours` = 24 | | SQLI-AX-001 | Batchjob er endt med fejl (nu, eller seneste kørsel af et gentaget job) | `BatchFailedLookbackHours` = 24 |
| SQLI-AX-002 | Batchjob kører længere end normalt, målt mod jobbets egne seneste kørsler | `BatchLongRunningFactor` = 2 × 90 %-fraktilen, mindst `BatchLongRunningMinimumMinutes` = 10, efter `BatchHistoryMinimumRuns` = 5 kørsler | | SQLI-AX-002 | Batchjob kører længere end normalt, målt mod jobbets egne seneste kørsler | `BatchLongRunningFactor` = 2 × 90 %-fraktilen, mindst `BatchLongRunningMinimumMinutes` = 10, efter `BatchHistoryMinimumRuns` = 5 kørsler |
| SQLI-AX-003 | Batchjob er ikke startet til tiden; efter et døgn "aldrig startet" | `BatchLateMinutes` = 30, `BatchNeverStartedHours` = 24 | | SQLI-AX-003 | Batchjob er ikke startet til tiden; efter et døgn "aldrig startet". Job planlagt for mere end `BatchAbandonedDays` siden regnes som opgivne og meldes ikke | `BatchLateMinutes` = 30, `BatchNeverStartedHours` = 24, `BatchAbandonedDays` = 30 |
| SQLI-AX-004 | Batchjob hænger i Udfører | `BatchStuckHours` = 6 (medmindre jobbet normalt kører så længe) | | SQLI-AX-004 | Batchjob hænger i Udfører | `BatchStuckHours` = 6 (medmindre jobbet normalt kører så længe) |
| SQLI-AX-005 | Nummerserie er ved at løbe tør, nu eller efter prognosen | `NumberSequenceWarnPercent` = 80, `NumberSequenceCriticalPercent` = 95, `ForecastWarnDays` = 90, `ForecastCriticalDays` = 30 | | SQLI-AX-005 | Nummerserie er ved at løbe tør, nu eller efter prognosen | `NumberSequenceWarnPercent` = 80, `NumberSequenceCriticalPercent` = 95, `ForecastWarnDays` = 90, `ForecastCriticalDays` = 30 |
| SQLI-AX-006 | Nummerseriens format har færre cifre end Højeste kræver | – | | SQLI-AX-006 | Nummerseriens format har færre cifre end Højeste kræver | – |
@ -75,7 +75,7 @@ Planlagt start er `BATCHJOB.ORIGSTARTDATETIME` (UTC) i AX 2009 og senere, og `BA
Hvert jobs historik er SQLInsights egen (`insight.AxBatchRuns`, gemt `EventDays` = 90 dage): i AX 2009 og senere fra `BATCHJOBHISTORY`, læst efter RECID; Axapta 3.0 og 4.0 gemmer ingen historik, så SQLInsight registrerer selv de kørsler, den ser blive afsluttet (kørselstiden måles fra første gang, den så jobbet køre, og korte kørsler, der starter og slutter mellem to målinger, kommer ikke med). Et job i Axapta 3.0 er klasse + firma + batchgruppe, fordi AX 3.0 laver en ny række for hver gentagelse. Hvert jobs historik er SQLInsights egen (`insight.AxBatchRuns`, gemt `EventDays` = 90 dage): i AX 2009 og senere fra `BATCHJOBHISTORY`, læst efter RECID; Axapta 3.0 og 4.0 gemmer ingen historik, så SQLInsight registrerer selv de kørsler, den ser blive afsluttet (kørselstiden måles fra første gang, den så jobbet køre, og korte kørsler, der starter og slutter mellem to målinger, kommer ikke med). Et job i Axapta 3.0 er klasse + firma + batchgruppe, fordi AX 3.0 laver en ny række for hver gentagelse.
**`STATUS` i Axapta 3.0 er ikke verificeret mod kundedata.** SQLInsight antager 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. Siden *Batchjob* viser de rå værdier, der findes, så de kan sammenlignes med batchlisten i AX. AX 2009 og senere følger Microsofts dokumenterede `BatchStatus` (0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet, 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret). **`STATUS` i Axapta 3.0** følger samme rækkefølge som AX 4.0 og senere: 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet. Venter (1) og Afsluttet (4) er kontrolleret mod en kundes data med [`dynamics-ax/verificer-ax30-batchstatus.sql`](dynamics-ax/verificer-ax30-batchstatus.sql); 0, 2 og 3 fandtes ikke i dataene. Siden *Batchjob* viser stadig de rå værdier. AX 2009 og senere følger Microsofts dokumenterede `BatchStatus` (0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet, 5 Klar, 6 Ikke kørt, 7 Annullerer, 8 Annulleret).
### Nummerserier og RecId ### Nummerserier og RecId

View file

@ -0,0 +1,53 @@
-- ===================================================================================================================
-- Verificér STATUS-værdierne i BATCH for Axapta 3.0
-- ===================================================================================================================
-- Formål: SQLInsight bruger for AX 3.0: 0 = Tilbageholdt, 1 = Venter, 2 = Udfører, 3 = Fejl, 4 = Afsluttet
-- (1 og 4 kontrolleret hos Cascorp 2026-10-03).
-- Tekst-labels for enums ligger ikke i databasen, så scriptet viser værdierne sammen med de
-- kolonner, der afslører betydningen (sluttid, fejlkode, planlagt start), og eksempler, som kan
-- slås op i AX-klienten (Basis > Forespørgsler > Batchliste).
-- Read-only: Kun SELECT. Ingen temp-tabeller, intet skrives. Kort lock timeout, så batchkørsler ikke blokeres.
-- Kør: I SSMS i AX-databasen (fx AXDBTEST på KGA-SQL-01\AX30). Skemaet er bmssa hos Cascorp;
-- hedder det dbo hos jer, så erstat "bmssa." med "dbo.".
-- ===================================================================================================================
SET NOCOUNT ON;
SET LOCK_TIMEOUT 2000;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 1. Fordeling: hvor mange job har hver STATUS, og hvad kendetegner dem?
SELECT
b.STATUS,
COUNT(*) AS antal,
SUM(CASE WHEN b.ENDDATE > '19000101' THEN 1 ELSE 0 END) AS med_sluttid,
SUM(CASE WHEN b.EXCEPTIONCODE <> 0 THEN 1 ELSE 0 END) AS med_fejlkode,
SUM(CASE WHEN DATEADD(second, b.STARTTIME, b.STARTDATE) > GETDATE()
THEN 1 ELSE 0 END) AS start_i_fremtiden,
SUM(CASE WHEN b.SESSIONIDX <> 0 THEN 1 ELSE 0 END) AS med_session,
MIN(DATEADD(second, b.STARTTIME, b.STARTDATE)) AS tidligste_start,
MAX(DATEADD(second, b.STARTTIME, b.STARTDATE)) AS seneste_start
FROM bmssa.BATCH AS b
GROUP BY b.STATUS
ORDER BY b.STATUS;
-- 2. Op til 5 eksempler pr. STATUS (nyeste først) til at slå op i AX-klientens batchliste.
SELECT x.STATUS, x.COMPANY, x.GROUPID, x.CLASSNUM, x.JOURNALID, x.EXECUTEDBY,
x.planlagt_start, x.slut, x.EXCEPTIONCODE, x.SESSIONIDX, x.RECID
FROM (
SELECT b.STATUS, b.COMPANY, b.GROUPID, b.CLASSNUM, b.JOURNALID, b.EXECUTEDBY,
DATEADD(second, b.STARTTIME, b.STARTDATE) AS planlagt_start,
CASE WHEN b.ENDDATE > '19000101'
THEN DATEADD(second, b.ENDTIME, b.ENDDATE) END AS slut,
b.EXCEPTIONCODE, b.SESSIONIDX, b.RECID,
ROW_NUMBER() OVER (PARTITION BY b.STATUS ORDER BY b.STARTDATE DESC, b.STARTTIME DESC) AS rn
FROM bmssa.BATCH AS b
) AS x
WHERE x.rn <= 5
ORDER BY x.STATUS, x.planlagt_start DESC;
-- 3. Hvilken feltype har STATUS ifølge AX' egen dictionary? (FIELDTYPE 4 = enum)
SELECT d.TABLEID, d.FIELDID, d.NAME, d.SQLNAME, d.FIELDTYPE
FROM bmssa.SQLDICTIONARY AS d
WHERE d.SQLNAME = 'STATUS'
AND d.TABLEID = (SELECT TOP (1) t.TABLEID FROM bmssa.SQLDICTIONARY AS t
WHERE t.FIELDID = 0 AND t.SQLNAME = 'BATCH');

View file

@ -13,6 +13,12 @@ Begge kommer fra et **opdateringsfeed**: en https-adresse eller en mappe/et fild
Læs dette, før en ny version installeres. Repository-skemaet opgraderes af agenten ved første start. Læs dette, før en ny version installeres. Repository-skemaet opgraderes af agenten ved første start.
### 1.5.2: Axapta 3.0-batchstatus rettet
- Batchjobbenes `STATUS` i Axapta 3.0 tolkes nu som i AX 4.0 og senere: 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet (kontrolleret mod en kundes data). Før 1.5.2 blev afsluttede job vist som tilbageholdte og ventende som kørende, så batchfund og -sider for AX 3.0 var forkerte. Fund fra den gamle tolkning lukkes ved næste måling.
- SQLI-AX-003 melder ikke længere ventende job, der var planlagt for mere end `SQLInsight:DynamicsAx:BatchAbandonedDays` (30) dage siden: de er opgivne rækker, ikke en aktuel fejl.
- Ingen skemaændringer.
### 1.5: Windows-servere og syntetiske tjek ### 1.5: Windows-servere og syntetiske tjek
- **Ny menu – Servere:** *Windows-servere* (CPU, hukommelse, diske med prognose for, hvornår de er fulde, tjenester, hændelseslog og planlagte opgaver) og *Syntetiske tjek* (TCP-port, HTTP(S) med certifikatets udløb, SQL-login og forespørgsel, DNS og Dynamics AX-logon), og det samme i API v1 under `/api/v1/windows-servers`, `/api/v1/synthetic-checks` og `/api/v1/server-findings` (ændringer kræver scope `config:write`, som i 1.3). Nye regler `SQLI-WIN-001` … `SQLI-WIN-013` og `SQLI-SYN-001` … `SQLI-SYN-003`. Se [windows-servere.md](windows-servere.md) og [syntetiske-tjek.md](syntetiske-tjek.md). - **Ny menu – Servere:** *Windows-servere* (CPU, hukommelse, diske med prognose for, hvornår de er fulde, tjenester, hændelseslog og planlagte opgaver) og *Syntetiske tjek* (TCP-port, HTTP(S) med certifikatets udløb, SQL-login og forespørgsel, DNS og Dynamics AX-logon), og det samme i API v1 under `/api/v1/windows-servers`, `/api/v1/synthetic-checks` og `/api/v1/server-findings` (ændringer kræver scope `config:write`, som i 1.3). Nye regler `SQLI-WIN-001` … `SQLI-WIN-013` og `SQLI-SYN-001` … `SQLI-SYN-003`. Se [windows-servere.md](windows-servere.md) og [syntetiske-tjek.md](syntetiske-tjek.md).
@ -158,18 +164,18 @@ Kører web i IIS, angives `"AppOffline": true` i stedet for `Service`. Så lægg
dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json
# MSI til installationer lavet med MSI'en (bygges på Linux) # MSI til installationer lavet med MSI'en (bygges på Linux)
installer/build-msi.sh 1.5.1 installer/build-msi.sh 1.5.2
# Programpakker: én zip pr. platform med mapperne Agent/ og Web/ # Programpakker: én zip pr. platform med mapperne Agent/ og Web/
# Samme opsætning som MSI'en (indbygget .NET), så en opdatering ikke kræver en runtime på serveren # Samme opsætning som MSI'en (indbygget .NET), så en opdatering ikke kræver en runtime på serveren
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.5.1 -o pkg/win-x64/Agent dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.5.2 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.5.1 -o pkg/win-x64/Web dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.5.2 -o pkg/win-x64/Web
# Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest) # Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest)
dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \ dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \
--feed feed --channel stable --rulepack rulepack-2026.10.14.json \ --feed feed --channel stable --rulepack rulepack-2026.10.14.json \
--release 1.5.1 --asset win-x64=sqlinsight-1.5.1-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.5.1-x64.msi \ --release 1.5.2 --asset win-x64=sqlinsight-1.5.2-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.5.2-x64.msi \
--asset linux-x64=sqlinsight-1.5.1-linux-x64.zip \ --asset linux-x64=sqlinsight-1.5.2-linux-x64.zip \
--notes https://git.seosoft.dk/seo/sqlinsight-pro/releases --notes https://git.seosoft.dk/seo/sqlinsight-pro/releases
``` ```

View file

@ -137,6 +137,9 @@ public sealed class DynamicsAxOptions
/// <summary>Late by this many hours: in practice never started (High).</summary> /// <summary>Late by this many hours: in practice never started (High).</summary>
public int BatchNeverStartedHours { get; set; } = 24; public int BatchNeverStartedHours { get; set; } = 24;
/// <summary>A waiting job planned more than this many days ago is abandoned and not reported as late.</summary>
public int BatchAbandonedDays { get; set; } = 30;
/// <summary>A batch job executing this many hours is stuck (SQLI-AX-004).</summary> /// <summary>A batch job executing this many hours is stuck (SQLI-AX-004).</summary>
public int BatchStuckHours { get; set; } = 6; public int BatchStuckHours { get; set; } = 6;
@ -189,6 +192,7 @@ public sealed class DynamicsAxOptions
{ {
BatchLateMinutes = Math.Max(1, BatchLateMinutes), BatchLateMinutes = Math.Max(1, BatchLateMinutes),
BatchNeverStartedHours = Math.Max(1, BatchNeverStartedHours), BatchNeverStartedHours = Math.Max(1, BatchNeverStartedHours),
BatchAbandonedDays = Math.Max(2, BatchAbandonedDays),
BatchStuckHours = Math.Max(1, BatchStuckHours), BatchStuckHours = Math.Max(1, BatchStuckHours),
BatchLongRunningFactor = Math.Max(1.1, BatchLongRunningFactor), BatchLongRunningFactor = Math.Max(1.1, BatchLongRunningFactor),
BatchLongRunningMinimumMinutes = Math.Max(1, BatchLongRunningMinimumMinutes), BatchLongRunningMinimumMinutes = Math.Max(1, BatchLongRunningMinimumMinutes),

View file

@ -514,8 +514,8 @@ public abstract class AxSchemaAdapter
/// Axapta 2.5/3.0. Verified against the real AX 3.0 schema from a customer (docs/dynamics-ax/schema-ax30.tsv): tables in /// Axapta 2.5/3.0. Verified against the real AX 3.0 schema from a customer (docs/dynamics-ax/schema-ax30.tsv): tables in
/// the bmssa schema, 32-bit RECID, BATCH with STARTDATE (datetime, local) + STARTTIME (int, seconds since midnight, /// the bmssa schema, 32-bit RECID, BATCH with STARTDATE (datetime, local) + STARTTIME (int, seconds since midnight,
/// local), NUMBERSEQUENCETABLE with NEXTREC/HIGHEST/FORMAT/BLOCKED/INUSE per DATAAREAID, SYSTEMSEQUENCES with /// local), NUMBERSEQUENCETABLE with NEXTREC/HIGHEST/FORMAT/BLOCKED/INUSE per DATAAREAID, SYSTEMSEQUENCES with
/// NEXTVAL/MAXVAL per TABID and DATAAREAID. The batch STATUS values are NOT verified against customer data (the schema /// NEXTVAL/MAXVAL per TABID and DATAAREAID. The batch STATUS values Venter (1) and Afsluttet (4) are checked against
/// file has no data): the UI shows the raw values found so the mapping can be checked. /// customer data; the UI still shows the raw values found.
/// </summary> /// </summary>
public sealed class Ax30Adapter : AxSchemaAdapter public sealed class Ax30Adapter : AxSchemaAdapter
{ {
@ -537,19 +537,12 @@ public sealed class Ax30Adapter : AxSchemaAdapter
}; };
/// <summary> /// <summary>
/// The Axapta 3.0 BatchStatus enum as SQLInsight assumes it: 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt. /// The Axapta 3.0 BatchStatus enum: 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet - the same order as AX 4.0
/// NOT verified against customer data; check with SELECT STATUS, COUNT(*) FROM bmssa.BATCH GROUP BY STATUS and the /// and the first five values of AX 2009+. Checked against a customer's BATCH data (docs/dynamics-ax/verificer-ax30-batchstatus.sql,
/// batch list in AX. The web UI shows the values found. /// 2026-10-03): every STATUS 4 row has an end time and no exception code (Afsluttet), and STATUS 1 rows have no end time
/// and planned starts long passed (Venter). 0, 2 and 3 were not present in the data and follow the enum order.
/// </summary> /// </summary>
public override AxBatchStatus BatchStatus(int status) => status switch public override AxBatchStatus BatchStatus(int status) => status is >= 0 and <= 4 ? JobStatus(status) : AxBatchStatus.Unknown;
{
0 => AxBatchStatus.Waiting,
1 => AxBatchStatus.Executing,
2 => AxBatchStatus.Error,
3 => AxBatchStatus.Finished,
4 => AxBatchStatus.Hold,
_ => AxBatchStatus.Unknown,
};
public override string JobKey(AxBatchJobRow row) public override string JobKey(AxBatchJobRow row)
{ {

View file

@ -12,6 +12,9 @@ public sealed record AxThresholds
/// <summary>Late by more than this many hours: it has in practice never started (High).</summary> /// <summary>Late by more than this many hours: it has in practice never started (High).</summary>
public int BatchNeverStartedHours { get; init; } = 24; public int BatchNeverStartedHours { get; init; } = 24;
/// <summary>A waiting job planned more than this many days ago is abandoned, not late: SQLI-AX-003 skips it.</summary>
public int BatchAbandonedDays { get; init; } = 30;
/// <summary>A batch job executing longer than this many hours is stuck (SQLI-AX-004).</summary> /// <summary>A batch job executing longer than this many hours is stuck (SQLI-AX-004).</summary>
public int BatchStuckHours { get; init; } = 6; public int BatchStuckHours { get; init; } = 6;

View file

@ -189,7 +189,10 @@ public sealed class AxBatchLateRule : DynamicsAxBatchRule
protected override IEnumerable<Finding> Check(AxBatchDatabase database, AxThresholds thresholds, DateTimeOffset now) protected override IEnumerable<Finding> Check(AxBatchDatabase database, AxThresholds thresholds, DateTimeOffset now)
{ {
var late = database.Jobs.Where(j => j.Late(now) is { } l && l >= TimeSpan.FromMinutes(thresholds.BatchLateMinutes)).OrderByDescending(j => j.Late(now)).ToList(); // Jobs planned long ago and never picked up are abandoned rows (e.g. a batch group no server serves), not a live problem.
var late = database.Jobs
.Where(j => j.Late(now) is { } l && l >= TimeSpan.FromMinutes(thresholds.BatchLateMinutes) && l < TimeSpan.FromDays(thresholds.BatchAbandonedDays))
.OrderByDescending(j => j.Late(now)).ToList();
foreach (var job in late) foreach (var job in late)
{ {
var behind = job.Late(now)!.Value; var behind = job.Late(now)!.Value;

View file

@ -212,8 +212,13 @@ public class AxInterpretationTests
[Fact] [Fact]
public void Batch_status_enums_per_version() public void Batch_status_enums_per_version()
{ {
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax30.BatchStatus(1)); // Axapta 3.0, checked against customer data: 1 = waiting (no end time), 4 = finished (end time, no exception code).
Assert.Equal(AxBatchStatus.Error, AxSchemaAdapter.Ax30.BatchStatus(2)); Assert.Equal(AxBatchStatus.Hold, AxSchemaAdapter.Ax30.BatchStatus(0));
Assert.Equal(AxBatchStatus.Waiting, AxSchemaAdapter.Ax30.BatchStatus(1));
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax30.BatchStatus(2));
Assert.Equal(AxBatchStatus.Error, AxSchemaAdapter.Ax30.BatchStatus(3));
Assert.Equal(AxBatchStatus.Finished, AxSchemaAdapter.Ax30.BatchStatus(4));
Assert.Equal(AxBatchStatus.Unknown, AxSchemaAdapter.Ax30.BatchStatus(5));
Assert.Equal(AxBatchStatus.Waiting, AxSchemaAdapter.Ax2009.BatchStatus(1)); Assert.Equal(AxBatchStatus.Waiting, AxSchemaAdapter.Ax2009.BatchStatus(1));
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax2012.BatchStatus(2)); Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax2012.BatchStatus(2));
Assert.Equal(AxBatchStatus.Canceled, AxSchemaAdapter.Ax2012.BatchStatus(8)); Assert.Equal(AxBatchStatus.Canceled, AxSchemaAdapter.Ax2012.BatchStatus(8));
@ -224,7 +229,7 @@ public class AxInterpretationTests
[Fact] [Fact]
public void Ax30_jobs_are_keyed_by_class_company_and_group_and_late_jobs_are_measured_from_the_planned_start() public void Ax30_jobs_are_keyed_by_class_company_and_group_and_late_jobs_are_measured_from_the_planned_start()
{ {
var row = new AxBatchJobRow(17, 1234, null, "dat", "NAT", 0, new DateTime(2026, 10, 2), 9 * 3600, null, null, null, null, -120); var row = new AxBatchJobRow(17, 1234, null, "dat", "NAT", 1, new DateTime(2026, 10, 2), 9 * 3600, null, null, null, null, -120);
var job = AxSchemaAdapter.Ax30.Interpret(row, new AxBatchHistory(), "AX30", null); var job = AxSchemaAdapter.Ax30.Interpret(row, new AxBatchHistory(), "AX30", null);

View file

@ -48,10 +48,10 @@ public class DynamicsAxTests(SqlServerFixture sql) : IAsyncLifetime
DECLARE @late datetime, @running datetime, @failed datetime; DECLARE @late datetime, @running datetime, @failed datetime;
SET @late = DATEADD(hour, -2, GETDATE()); SET @running = DATEADD(minute, -30, GETDATE()); SET @failed = DATEADD(hour, -1, GETDATE()); SET @late = DATEADD(hour, -2, GETDATE()); SET @running = DATEADD(minute, -30, GETDATE()); SET @failed = DATEADD(hour, -1, GETDATE());
INSERT bmssa.BATCH (RECID, CLASSNUM, COMPANY, GROUPID, STATUS, STARTDATE, STARTTIME, ENDDATE, ENDTIME) INSERT bmssa.BATCH (RECID, CLASSNUM, COMPANY, GROUPID, STATUS, STARTDATE, STARTTIME, ENDDATE, ENDTIME)
VALUES (1, 101, 'dat', 'NAT', 0, CONVERT(date, @late), DATEDIFF(second, CONVERT(date, @late), @late), '19000101', 0), VALUES (1, 101, 'dat', 'NAT', 1, CONVERT(date, @late), DATEDIFF(second, CONVERT(date, @late), @late), '19000101', 0),
(2, 102, 'dat', 'NAT', 2, CONVERT(date, @failed), DATEDIFF(second, CONVERT(date, @failed), @failed), CONVERT(date, @failed), DATEDIFF(second, CONVERT(date, @failed), @failed) + 60), (2, 102, 'dat', 'NAT', 3, CONVERT(date, @failed), DATEDIFF(second, CONVERT(date, @failed), @failed), CONVERT(date, @failed), DATEDIFF(second, CONVERT(date, @failed), @failed) + 60),
(3, 103, 'dat', '', 1, CONVERT(date, @running), DATEDIFF(second, CONVERT(date, @running), @running), '19000101', 0), (3, 103, 'dat', '', 2, CONVERT(date, @running), DATEDIFF(second, CONVERT(date, @running), @running), '19000101', 0),
(4, 104, 'dmo', '', 3, CONVERT(date, @failed), 0, CONVERT(date, @failed), 600); (4, 104, 'dmo', '', 4, CONVERT(date, @failed), 0, CONVERT(date, @failed), 600);
INSERT bmssa.NUMBERSEQUENCETABLE (RECID, NUMBERSEQUENCE, TXT, LOWEST, HIGHEST, NEXTREC, FORMAT, BLOCKED, CONTINUOUS, CYCLIC, MANUAL, INUSE, INSERT bmssa.NUMBERSEQUENCETABLE (RECID, NUMBERSEQUENCE, TXT, LOWEST, HIGHEST, NEXTREC, FORMAT, BLOCKED, CONTINUOUS, CYCLIC, MANUAL, INUSE,
LATESTCLEANDATE, LATESTCLEANTIME, CLEANINTERVAL, DATAAREAID) LATESTCLEANDATE, LATESTCLEANTIME, CLEANINTERVAL, DATAAREAID)
VALUES (10, 'Debitor', 'Debitorer', 1, 99999, 97000, 'DEB-#####', 0, 0, 0, 0, 1, '19000101', 0, 0, 'dat'), VALUES (10, 'Debitor', 'Debitorer', 1, 99999, 97000, 'DEB-#####', 0, 0, 0, 0, 1, '19000101', 0, 0, 'dat'),

View file

@ -79,15 +79,15 @@ public class DynamicsAxRuleTests
[Fact] [Fact]
public void Ax001_a_batch_job_in_error_is_high_and_an_old_error_is_not_reported() public void Ax001_a_batch_job_in_error_is_high_and_an_old_error_is_not_reported()
{ {
var failed = Job30(Batch30(10, 2, Utc.AddHours(-3))); var failed = Job30(Batch30(10, 3, Utc.AddHours(-3)));
var old = Job30(Batch30(11, 2, Utc.AddDays(-3), classNumber: 99)); var old = Job30(Batch30(11, 3, Utc.AddDays(-3), classNumber: 99));
var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, old))).Findings); var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, old))).Findings);
Assert.Equal(Severity.High, finding.Severity); Assert.Equal(Severity.High, finding.Severity);
Assert.Equal("database: AX30 · batch: 4711|dat|NAT", finding.Subject); Assert.Equal("database: AX30 · batch: 4711|dat|NAT", finding.Subject);
Assert.Contains("Fejl (STATUS = 2)", finding.Evidence.Single(e => e.Name == "Status i AX").Value, StringComparison.Ordinal); Assert.Contains("Fejl (STATUS = 3)", finding.Evidence.Single(e => e.Name == "Status i AX").Value, StringComparison.Ordinal);
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchFailedRule(), With(Batch(Job30(Batch30(12, 0, Utc.AddMinutes(10)))))).Outcome); Assert.Equal(RuleOutcome.Passed, Run(new AxBatchFailedRule(), With(Batch(Job30(Batch30(12, 1, Utc.AddMinutes(10)))))).Outcome);
} }
[Fact] [Fact]
@ -114,7 +114,7 @@ public class DynamicsAxRuleTests
[InlineData(100, Severity.High)] [InlineData(100, Severity.High)]
public void Ax002_a_job_running_much_longer_than_its_own_history(int runningMinutes, Severity? expected) public void Ax002_a_job_running_much_longer_than_its_own_history(int runningMinutes, Severity? expected)
{ {
var row = Batch30(20, 1, Utc.AddHours(-5)); var row = Batch30(20, 2, Utc.AddHours(-5));
var job = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)), firstSeen: Utc.AddMinutes(-runningMinutes)); var job = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)), firstSeen: Utc.AddMinutes(-runningMinutes));
var result = Run(new AxBatchLongRunningRule(), With(Batch(job))); var result = Run(new AxBatchLongRunningRule(), With(Batch(job)));
@ -132,7 +132,7 @@ public class DynamicsAxRuleTests
[InlineData(60 * 30, Severity.High)] [InlineData(60 * 30, Severity.High)]
public void Ax003_a_waiting_job_past_its_planned_start_is_late_and_after_a_day_never_started(int minutesLate, Severity? expected) public void Ax003_a_waiting_job_past_its_planned_start_is_late_and_after_a_day_never_started(int minutesLate, Severity? expected)
{ {
var job = Job30(Batch30(30, 0, Utc.AddMinutes(-minutesLate))); var job = Job30(Batch30(30, 1, Utc.AddMinutes(-minutesLate)));
var finding = Run(new AxBatchLateRule(), With(Batch(job))).Findings.SingleOrDefault(); var finding = Run(new AxBatchLateRule(), With(Batch(job))).Findings.SingleOrDefault();
@ -144,10 +144,21 @@ public class DynamicsAxRuleTests
} }
} }
[Fact]
public void Ax003_a_job_waiting_for_years_is_abandoned_not_late()
{
// Seen at a customer: 25 waiting AX 3.0 jobs with an empty batch group, planned 2007-2018.
var abandoned = Job30(Batch30(32, 1, Utc.AddDays(-Thresholds.BatchAbandonedDays - 1)));
var recent = Job30(Batch30(33, 1, Utc.AddDays(-Thresholds.BatchAbandonedDays + 1)));
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLateRule(), With(Batch(abandoned))).Outcome);
Assert.Single(Run(new AxBatchLateRule(), With(Batch(recent))).Findings);
}
[Fact] [Fact]
public void Ax003_held_jobs_are_not_late() public void Ax003_held_jobs_are_not_late()
{ {
var held = Job30(Batch30(31, 4, Utc.AddDays(-2))); var held = Job30(Batch30(31, 0, Utc.AddDays(-2)));
Assert.Equal(AxBatchStatus.Hold, held.Status); Assert.Equal(AxBatchStatus.Hold, held.Status);
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLateRule(), With(Batch(held))).Outcome); Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLateRule(), With(Batch(held))).Outcome);
@ -156,7 +167,7 @@ public class DynamicsAxRuleTests
[Fact] [Fact]
public void Ax004_a_job_executing_for_hours_is_stuck_unless_it_normally_runs_that_long() public void Ax004_a_job_executing_for_hours_is_stuck_unless_it_normally_runs_that_long()
{ {
var row = Batch30(40, 1, Utc.AddHours(-9)); var row = Batch30(40, 2, Utc.AddHours(-9));
var stuck = Job30(row, firstSeen: Utc.AddHours(-7)); var stuck = Job30(row, firstSeen: Utc.AddHours(-7));
var normallyLong = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromHours(6)), Utc.AddHours(-7)); var normallyLong = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromHours(6)), Utc.AddHours(-7));
@ -314,8 +325,8 @@ public class DynamicsAxRuleTests
// AX 3.0 keeps a BATCH row per recurrence: the failed run and the next waiting one share the job key. // AX 3.0 keeps a BATCH row per recurrence: the failed run and the next waiting one share the job key.
var history = new AxBatchHistory(); var history = new AxBatchHistory();
history.Add("AX30", new AxBatchRun("4711|dat|NAT", "R1", "Lagerafslutning", "dat", Utc.AddHours(-3), Utc.AddHours(-2), AxBatchStatus.Error)); history.Add("AX30", new AxBatchRun("4711|dat|NAT", "R1", "Lagerafslutning", "dat", Utc.AddHours(-3), Utc.AddHours(-2), AxBatchStatus.Error));
var failed = Job30(Batch30(50, 2, Utc.AddHours(-3)), history) with { EndedUtc = Utc.AddHours(-2) }; var failed = Job30(Batch30(50, 3, Utc.AddHours(-3)), history) with { EndedUtc = Utc.AddHours(-2) };
var waiting = Job30(Batch30(51, 0, Utc.AddHours(1)), history); var waiting = Job30(Batch30(51, 1, Utc.AddHours(1)), history);
var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, waiting))).Findings); var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, waiting))).Findings);
@ -326,7 +337,7 @@ public class DynamicsAxRuleTests
[Fact] [Fact]
public void Ax004_one_long_earlier_run_does_not_hide_a_stuck_job_and_ax002_leaves_a_stuck_job_to_ax004() public void Ax004_one_long_earlier_run_does_not_hide_a_stuck_job_and_ax002_leaves_a_stuck_job_to_ax004()
{ {
var row = Batch30(60, 1, Utc.AddHours(-9)); var row = Batch30(60, 2, Utc.AddHours(-9));
var oneLongRun = Job30(row, History("4711|dat|NAT", 1, TimeSpan.FromHours(10)), Utc.AddHours(-7)); var oneLongRun = Job30(row, History("4711|dat|NAT", 1, TimeSpan.FromHours(10)), Utc.AddHours(-7));
Assert.Single(Run(new AxBatchStuckRule(), With(Batch(oneLongRun))).Findings); Assert.Single(Run(new AxBatchStuckRule(), With(Batch(oneLongRun))).Findings);
@ -338,7 +349,7 @@ public class DynamicsAxRuleTests
[Fact] [Fact]
public void An_executing_job_without_any_start_time_is_skipped_not_an_error() public void An_executing_job_without_any_start_time_is_skipped_not_an_error()
{ {
var unknown = Job30(Batch30(70, 1, Utc.AddHours(-9)), History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20))); var unknown = Job30(Batch30(70, 2, Utc.AddHours(-9)), History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)));
Assert.Null(unknown.Running(Now)); Assert.Null(unknown.Running(Now));
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(unknown))).Outcome); Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(unknown))).Outcome);