Archiv der Kategorie: Azure

Azure Analysis Services automatisch Pausieren

Bei einem meiner Kunden hatten wir mehrere Azure Analysis Services im Einsatz. Wenn die kleinsten Tarife nicht mehr ausreichen, kann das mit der Zeit eine teure Angelegenheit werden.

Deshalb haben wir folgende Struktur in den Analysis Services, die in Azure gehostet werden, aufgebaut:

  • Jedes Projekt hat seinen eigenen Entwicklungsserver
  • Jedes Projekt hat seinen eigenen Testserver
  • Es gibt für die Projekte gemeinsame genutzte Produktivserver

Dabei verwenden wir bei den Entwicklungs- und Testservern den niedrigst möglichen Tarif (in der Regel D1, B1).

Darüber sollen die Entwicklungs- und Testserver nur dann laufen und somit Geld kosten, wenn sie benötigt werden. Das heißt, wenn an einem Projekt gerade weder entwickelt noch getestet wird, sind die beiden betreffenden Azure Analysis Services (AAS) pausiert.

Um dies nicht nur manuell durch den Entwickler umzusetzen, haben wir einen Automatismus implementiert:

  • Zunächst gibt es eine relationale Tabelle, in der alle AAS aufgeführt sind
  • Für jeden AAS kann man definieren, bis wann er laufen soll
    (Wenn man z.B. im Test ist, möchte man nicht, dass der Server nachts automatisiert pausiert wird)
  • Abends läuft ein Job (eine Data Factory Pipeline), die alle auszuschaltenden AAS pausiert, falls sie noch laufen.

Bei der Umsetzung habe ich mich vom hier zu findenden guten Artikel inspierieren lassen.

Erste Pipeline „SuspendOrResumeAAS“

Zunächst erstelle ich eine Pipeline „SuspendOrResumeAAS“:

Pipeline SuspendOrResumeAAS

Man sieht hier schon gut, woraus diese Pipeline besteht:

  • Es gibt 3 Parameter:
    • action: Soll die AAS-Instanz pausiert (supend) oder gestartet (resume) werden?
    • aasName: Name der AAS
    • ressourceGroupName: Name der Ressourcengruppe
  • Es gibt eine Variable (was man im Screen Shot nicht sieht):
    • subscriptionId (Wir werden das später für den API-Aufruf benötigen)

Was macht die Pipeline?

  • Sie ermittelt zuerst den Status des AAS
  • Dann wird überprüft, ob der Status zu der Aktion passt (Man kann eine pausierte AAS nicht pausieren 🙂 )
  • Wenn der Status OK ist, wird der API-Aufruf mit der gewünschten Aktion durchgeführt.

Die Schritte beleuchten wir jetzt näher:

Für die Ermittlung des Status (infoZuAAS) verwenden wir eine Web-Aktivität:

In den Einstellungen bauen wir die URL via dynamischen Inhalt zusammen:

@concat('https://management.azure.com/subscriptions/', variables('subscriptionId') , '/resourceGroups/', pipeline().parameters.ressourceGroupName, '/providers/Microsoft.AnalysisServices/servers/', pipeline().parameters.aasName, '?api-version=2017-08-01')

Als Methode wird „GET“ eingestellt.

Wie auch im zitierten Artikel vorgeschlagen, verweden wir MSI als Authentifizierung (als Ressource „https://management.azure.com/“ eintragen).

Dazu müssen wir für jede betreffende AAS unter „Access Control (IAM)“ den Punkt „Add role assignments“ auswählen. Dort definieren wir als Contributor die Data Factory, in der wir unsere Pipeline erstellen:

Add role assignment: DF als Contributor des AAS

Wenn wir diese Aktivität im Debug-Modus starten, sehen wir

Debuginformation zu infoZuAAS

Über die Pfeile (links: Input, rechts: Output) können wir die aufrufende URL kontrollieren:

Input

Unter Output sieht man:

Output

Hier sehen wir unter properties > state „Paused“, was heißt, dass die AAS pausiert ist. Für eine laufende AAS ist der state „Succeeded“.

Dies nutzen wir dann gleich in der If-Abfrage (isInStateForAction): Um den Status in der Bedingung abzufragen, kann man via Code darauf zugreifen. Mit activity().output erhält man den gesamten Output und kann dann im JSON über . auf die einzelnen Attribute in der Hierarchie zugreifen:

activity('infoZuAAS').output.properties.state

Der gesamte Code für die „Expression“ in der „If Condition“ sieht so aus:

@or(
and(equals(activity('infoZuAAS').output.properties.state, 'Paused'),equals(toLower(pipeline().parameters.action), 'resume'))
,
and(equals(activity('infoZuAAS').output.properties.state, 'Succeeded'),equals(toLower(pipeline().parameters.action), 'suspend'))
)

Die innere Aktivität ist wieder eine Web-Aktivität (pause or resume AAS):

Diese Web-Aktivität unterscheidet sich von der vorhergehenden in der URL, in der nun die Aktion (suspend oder resume) mit angegeben wird. Außerdem ist die Methode POST. Deshalb muss der Text mit angegeben werden – auch wenn die AAS-API das eigentlich nicht benötigt, weswegen wir hier {"Dummy":"Dummy"} eintragen.

Die URL wird mit dieser Formel definiert:

@concat('https://management.azure.com/subscriptions/', variables('subscriptionId'), '/resourceGroups/', pipeline().parameters.ressourceGroupName, '/providers/Microsoft.AnalysisServices/servers/', pipeline().parameters.aasName, '/' , pipeline().parameters.action, '?api-version=2017-08-01')

Authentifizierung und Ressource wird wie oben gesetzt.

Zweite Pipeline: alleAASausschalten

Pipeline alleAASausschalten

Die Suche/Lookup-Aktivität „lies Config Tabelle“ liest per Query aus einer SQL-Server-Tabelle, welche AAS auszuschalten sind:

SELECT AAS_Name, RessourceGroupName FROM Management.[Config_AAS_Shutdown]
WHERE getdate() > keep_Online_Until

In der For-Each-Schleife „jedenAASausschalten“ ist folgende Einstellung zu machen:

@activity('lies Config Tabelle').output.value

Und innerhalb der Schleife wird eine Aktivität ausgeführt:

die unter (1) erstellte Pipeline aufrufen

Als Parameter übergeben wir an diese Pipeline:

  • action: suspend (fest definiert)
  • aasName: Formel @item().AAS_Name
  • ressourceGroupName @item().RessourceGroupName

Somit müssen wir nur noch einen Trigger täglich um 19 Uhr definieren.

Weitere Möglichkeiten

Wir haben darauf verzichtet, einen Automatismus zu erstellen, der am Vormittag die Entwickler-Server wieder hochfährt.

Hintergrund war, dass wir die Entwicklungs- und Testserver nicht jeden Tag brauchen. Sie schnell manuell zu starten, ist kein großer Aufwand – deswegen lassen wir das so.

Aber natürlich wäre es einfach, ganz analog eine Pipeline zu erstellen.

Uns war aber wichtig, den hier vorgestellten Weg zu implementieren, da man als Entwickler schnell mal vergisst, einen AAS auszuschalten – und mit diesem Automatismus ist das kein Problem, da jede Nacht überprüft wird, ob die AAS aus sind – und, wenn nicht, wieder ausgeschaltet werden.

Azure Cube-Verarbeitung von on premise

In einem Projekt, in dem die gesamte Datenhaltung und Datenverarbeitung on premise war (SQL + SSIS), haben wir einen Azure Analysis Services-Cube benutzt.

Für die Verarbeitung dieses Cubes haben wir nach einfachen Wegen gesucht, wie wir die Cube-Verarbeitung von SSIS / SQL Agent aus anstarten könnten.

Im Web findet man ja einiges zu Cube-Verarbeitung von Azure Analysis Services-Cubes (z.B. Process Azure Analysis Services Models with Azure Data Factory v2 oder Automating Azure Analysis Services processing with Azure Functions). Die erste Methode haben wir übrigens in einem anderen Projekt (das Azure Data Factory verwendete) verwendet. Allerdings hat dies den Nachteil, dass die ADF Managed Service Identity als Administrator des Analysis Services eingetragen werden muss – also mehr Rechte braucht als eigentlich nötig.

Aber für unseren Fall wollten wir eine möglichst einfache Lösung innerhalb SSIS.

Zunächst schauen wir uns an, wie wir einen On-Prem-Analysis-Services-Cube verarbeiten würden. Dazu reicht im SSIS eine Execute-SQL-Task mit folgenden Einstellungen:

  • Connection ist eine OLE DB-Connection, z.B. mit folgendem Connection-String: Data Source=<ServerName>;Initial Catalog=<Cube-Datenbank-Name>;Provider=MSOLAP.7;Integrated Security=SSPI;
Beispiel für eine OLE DB Connection auf den Cube
Connection auf SSAS
  • Im Feld „SQL-Statement“ trägt man den SSAS-Befehl ein (den man sich z.B. im SQL Server Management Studio skripten lassen kann):
{"refresh": 
{"type": "full",
"objects": [
{"database": "NetzwerkControlling"}
]
}
}

Für eine Verarbeitung eines Azure-Cubes können wir ganz genauso vorgehen. Nur der ConnectionString ist zu ändern:

  • Als Data Source ist der Azure Analysis Service einzutragen, also irgendwie so: asazure://westeurope.asazure.windows.net/<Server>
  • Wir müssen einen User angeben, da wir nicht den User verwenden können, unter dem der Prozess on prem läuft. Also sieht unser ConnectionString so aus:
    Data Source=asazure://westeurope.asazure.windows.net/<Server>;User ID=<Username>@<Azure Active Directory>;Initial Catalog=<Cube-Datenbank-Name>;Provider=MSOLAP.8;Persist Security Info=False;
  • Dann müssen wir in dem Attribut Password der Connection das Passwort dieses Users mitgeben.

Natürlich wollen wir das Passwort nicht im plain text irgendwo stehen haben. Deswegen reichen wir das Passwort über einen Paket-Parameter herein, den wir als sensitive (vertraulich) definieren. Dann können wir bei der Konfiguration im Katalog oder im SQL Server Agent das Passwort eintippen und es ist sicher in der SSISDB gespeichert:

Im Paket sieht das so aus:

Paket-Parameter „Passwort“ – als vertraulich definiert
Eigenschaften der SSAS-Connection in SSIS
Eigenschaften der SSAS-Connection in SSIS

In diesem Fall habe ich sogar den Connection-String auch noch von außen hereingereicht.

Bei der Konfiguration im SQL Agent stellt man dann das Passwort ein:

Konfiguration des Paket-Aufrufs im SQL Server Agent mit Passwort-Eingabe

Damit können wir mit dem gleichen Code lokale und Azure-Analysis-Services-Cubes verarbeiten – lediglich den Connectionstring mussten wir anpassen und im Azure-Analysis-Services-Fall das Passwort mit angeben.

Dies hat folgende Vorteile:

  • einheitliche Code-Basis – ein Code für beides
  • Passwort sicher gespeichert
  • im Gegensatz zu obiger Azure Data Factory-Variante
    • der User benötigt nur process-Rechte auf dem Cube
    • Der Aufruf ist automatisch synchron, so dass man Fehler einfach mitbekommt.

Datumsdimension in SSAS (ab 2016) / Azure

In meinen Projekten kommt es natürlich oft vor, dass man Datumsdimensionen benötigt.

Dazu verwende ich Stored Functions im SQL Server, um die Datumswerte mit allen gewünschten Attributen (wie Wochentag, Monat, Kalenderwoche, etc.) an den Analysis Services-Dienst weiter zu geben. Ich will das hier beschreiben, weil seit SSAS 2017 der Aufruf von Stored Functions gar nicht mehr so offensichtlich ist (wenn man die depracted Data Sources nicht verwenden will).

Das Vorgehen ist wie folgt:

  • Ermittle die Datumswerte, die die Datumsdimension enthalten soll. In der Regel ist das entweder ein Zeitraum oder distinkte Werte (z.B. bei einer Dimension, in der der Datenstand ausgewählt werden kann)
  • Liefere zu den ermittelten Datumswerten alle Attribute wie Wochentag, Monat, Kalenderwoche, etc.)
  • Gib diese Daten an den SSAS weiter

Wir benutzen hier bewusst einen getrennten Ansatz, erst die anzeigenden Datumswerte zu ermitteln und dann dazu die Attribute zu erzeugen. Dadurch erreichen wir die höchst mögliche Flexibilität. Man könnte zum Beispiel folgendes machen:

  • Wir nehmen alle Datumswerte vom Minimum der auftretenden Werte bis zum Maximum
  • Da aber Fehleingaben enthalten sind, und wir nicht alle Datumswerte vom 1.1.2000 bis 1.1.3000 in unserer Dimension haben wollen, nehmen wir nur die Datumswerte vom Minimum bis zum Ende nächsten Jahres und alle weiteren DISTINCTen Werte, die vorkommen.

Wir gehen so vor:

Als erstes legen wir einen Type an, der eine Liste von Datumswerten halten kann:

CREATE TYPE [dbo].[typ_Datumswerte] AS TABLE(
	[datum] [date] NOT NULL,
	PRIMARY KEY CLUSTERED 
(
	[datum] ASC
)WITH (IGNORE_DUP_KEY = OFF)
)
GO

In eine Variable dieses Datentyps können wir nun beliebige Datumswerte speichern. Wenn man zum Beispiel eine distinkte Menge an Datumswerten aus einer Faktentabelle speichern will, kann man das so machen:

DECLARE @d AS typ_Datumswerte
INSERT INTO @d SELECT DISTINCT Datum FROM Fakt_Auftragsbestand

Meistens benötigt man aber einen ganzen Zeitraum an allen möglichen Datumswerten. Dafür habe ich eine triviale Stored Function geschrieben:

-- =============================================
-- Author:		Martin Cremer
-- Create date: 07.07.2015
-- Description:	liefert alle Tage eines bestimmten Zeitraums
-- =============================================
CREATE FUNCTION [dbo].[f_alle_Datumswerte] 
(
	@von date, 
	@bis date
)
RETURNS 
@tab TABLE 
(
	datum date	 
)
AS
BEGIN
	while @von <= @bis
	begin
		insert into @tab select @von

		set @von = dateadd(d, 1, @von)
	end
	
	RETURN 
END
GO

Um einen ganzen Zeitraum nun in eine Variable des oben definierten Typs zu schreiben, geht man so vor:

DECLARE @d AS typ_Datumswerte
INSERT INTO @d 
SELECT * FROM dbo.f_alle_Datumswerte( 
	(SELECT min(Datum) FROM Fakt_Auftragsbestand),
	(SELECT max(Datum) FROM Fakt_Auftragsbestand)
)

Damit hätten wir den ersten Punkt erledigt. Kommen wir nun dazu, alle benötigten Attribute zu ermitteln. Dafür gibt es ein paar Hilfsfunktionen, deren 1. Version ich sogar am 1.1.2009 hier schon im Blog veröffentlicht hatte, und eine Funktion, die alles zusammenfasst. Zunächst die Hilfsfunktionen (auf die heutige Zeit angepasst, in der der SQL Server nativ die deutsche ISO-Woche ermitteln kann):

-- =============================================
-- Author:		Martin Cremer
-- Create date: 16.8.2006
-- Description:	ermittelt aus dem Datum den Wochentag (unabhängig von SET DATEFIRST): 1=Montag - 7=Sonntag
-- =============================================
CREATE FUNCTION [dbo].[getWochentag] 
(
	@dat date
)
RETURNS int
AS
BEGIN
	DECLARE @Result int
	SELECT @Result = (datepart(dw, @dat) - 1 + @@datefirst -1) % 7 +1
	RETURN @Result
END
GO
CREATE FUNCTION [dbo].[getKW_Jahr](@h as date)
returns int
as
begin
    return case 
		when datepart(isowk, @h)>50 and month(@h) = 1 then year(@h)-1 
		when datepart(isowk, @h)=1 and month(@h) = 12 then year(@h)+1
		else year(@h)
	end 
end
GO
CREATE FUNCTION [dbo].[getKW_Woche](@h as date)
returns int
as
begin
	return  datepart(isowk, @h)
end
GO

Die geradlinige zusammenfassende Funtkion sieht dann so aus:

-- =============================================
-- Author:		Martin Cremer
-- Create date: 07.07.2015
-- Description:	liefert zu den übergebenen Datumswerten alle Attribute
-- =============================================
CREATE FUNCTION [dbo].[f_Datumsattribute] 
(
	@datumswerte as dbo.typ_Datumswerte readonly
)
RETURNS 
@Ergebnis TABLE 
(
	Datum date NOT NULL, 
	[KW_ID] INT NOT NULL, 
	[KW_Jahr] SMALLINT NOT NULL,
	[KW] NVARCHAR(10) NOT NULL,
	[KW_Nr] SMALLINT NOT NULL,
	[Monat_ID] INT NOT NULL,
	[Monat_OhneJahr_ID] TINYINT NOT NULL,
	[Monat] NVARCHAR(8) NOT NULL,
	[Monat_OhneJahr] NVARCHAR(3) NOT NULL,
	[Jahr] SMALLINT NOT NULL,
	[Quartal_ID] SMALLINT NOT NULL,
	[Quartal_OhneJahr_ID] TINYINT NOT NULL,
	[Quartal_OhneJahr] NVARCHAR(50) NOT NULL,
	[Quartal] NVARCHAR(50) NOT NULL,
	[Wochentag_ID] int NOT NULL,
	[Wochentag] nvarchar(20) NOT NULL,
	[Wochentag_Kürzel] nvarchar(2) NOT NULL
)
AS
BEGIN
	INSERT INTO @Ergebnis
	(Datum, [KW_ID], [KW_Jahr], [KW], [KW_Nr], [Monat_ID], [Monat_OhneJahr_ID], [Monat], [Monat_OhneJahr], [Jahr], 
		 [Quartal_ID], [Quartal_OhneJahr_ID], [Quartal_OhneJahr], [Quartal], [Wochentag_ID], [Wochentag], [Wochentag_Kürzel])
	SELECT 
		   x.datum,
		   x.KW_Jahr * 100 + x.KW /* 201501 für KW 01/2015*/,
		   x.KW_Jahr /*2015*/,
		   'KW ' + RIGHT('0' + CONVERT(NVARCHAR(2), x.KW), 2) + '/' + CONVERT(NVARCHAR(4), x.KW_Jahr) /* KW 01/2015*/,
		   x.KW /*1*/,
		   x.jahr * 100 + x.Monat /* 201501 für Jan 2015 */,
		   x.monat /* 1 */,
		   monate.monatsname + ' ' + CONVERT(NVARCHAR(4), x.jahr) /* Jan 2015 */,
		   monate.monatsname /* Jan */,
		   x.jahr,
		   x.jahr * 10 + x.quartal /* 20151 für Q1 2015 */,
		   x.quartal /* 1 */,
		   'Q' + CONVERT(NVARCHAR(1), x.quartal) /* Q1 */,
		   'Q' + CONVERT(NVARCHAR(1), x.quartal) + ' ' + CONVERT(NVARCHAR(4), x.jahr),
		   x.wochentagID,
		   Wochentage.Wochentagname, 
		   Wochentage.Wochentagkurz
		FROM
			(SELECT [dbo].[getKW_Jahr](d.datum) AS KW_Jahr, [dbo].[getKW_Woche](d.datum) AS KW, 
			MONTH(d.datum) AS monat,
			datepart(QUARTER, d.datum) AS quartal,
			year(d.datum) AS jahr,
			[dbo].[getWochentag](d.datum) as wochentagID,
			d.datum
			FROM @datumswerte as d) AS x
		LEFT JOIN 
			(SELECT 1 AS monat, 'Jan' AS Monatsname UNION ALL
			 SELECT 2, 'Feb' UNION ALL
			 SELECT 3, 'Mär' UNION ALL
			 SELECT 4, 'Apr' UNION ALL
			 SELECT 5, 'Mai' UNION ALL
			 SELECT 6, 'Jun' UNION ALL
			 SELECT 7, 'Jul' UNION ALL
			 SELECT 8, 'Aug' UNION ALL
			 SELECT 9, 'Sep' UNION ALL
			 SELECT 10, 'Okt' UNION ALL
			 SELECT 11, 'Nov' UNION ALL
			 SELECT 12, 'Dez' ) AS monate
		ON x.monat = monate.monat
		LEFT JOIN 
			(SELECT 1 as WochentagID, 'Montag' Wochentagname, 'Mo' Wochentagkurz UNION ALL
			 SELECT 2, 'Dienstag', 'Di' UNION ALL
			 SELECT 3, 'Mittwoch', 'Mi' UNION ALL
			 SELECT 4, 'Donnerstag', 'Do' UNION ALL
			 SELECT 5, 'Freitag', 'Fr' UNION ALL
			 SELECT 6, 'Samstag', 'Sa' UNION ALL
			 SELECT 7, 'Sonntag', 'So' ) as Wochentage
		ON x.wochentagID = Wochentage.WochentagID
	RETURN 
END
GO

Somit kann man nun obige Ermittlung der gewünschten Datumswerte um die Ausgabe der Attribute erweitern, also zum Beispiel so:

declare @tage as [dbo].[typ_Datumswerte]
INSERT INTO @tage select * from dbo.f_alle_Datumswerte(convert(date, '1.1.2020', 104), convert(date, '31.12.' + convert(nvarchar(4), year(getdate())), 104))
select * from dbo.f_Datumsattribute(@tage)

Das Ergebnis sieht dann so aus:

Ergebnis Datumswerte

Nun müssen wir dieses SQL nur noch im SSAS ausführen lassen. Seit viele Neuerungen aus PowerBI in das Produkt SSAS einfließen, hat sich auch die Art und Weise geändert, wie man das Ergebnis von SQL-Statements im SSAS einbinden kann. Früher war es ja ganz einfach ein SQL-Statement anstelle einer Tabelle (bzw. View) zu verwenden, heute muss man wie folgt vorgehen [Die Idee dazu fand ich in Chris Webbs sehr gutem BI-Blog (hier).]:

Wenn man in Visual Studio eine neue Tabelle hinzufügt, wird im Hintergrund M-Code erzeugt, der in etwa so aussieht:

let
    Source = #"SQL/<host>;<Datenbank>",
    meineTabelle = Source{[Schema="dbo",Item="Beispiel"]}[Data]
in
    meineTabelle

Über die Properties der Tabelle > Quelldaten > (Zum Bearbeiten klicken) kann man diesen Code einsehen.

Zu verstehen ist der Code ja ganz leicht: Über Source wird die SQL-Connection auf dem Host <host> und Datenbank <Datenbank> geöffnet. Daraus wird im obigen Beispiel die Tabelle dbo.Beispiel geladen.

Wenn wir nun obiges SQL ausführen wollen, fügen wir zunächst auf den normalen Weg im UI eine neue Tabelle hinzu (welche ist vollkommen egal). Danach bearbeiten wir dieses M-Statement zu

let
    Source = #"SQL/<Host>;<Datenbank>",
    Entlassungsdatum = Value.NativeQuery(
        Source,
        "declare @tage as [dbo].[typ_Datumswerte]
INSERT INTO @tage select * from dbo.f_alle_Datumswerte(convert(date, '1.1.2020', 104), convert(date, '31.12.' + convert(nvarchar(4), year(getdate())), 104))
select * from dbo.f_Datumsattribute(@tage)"
    )
in
    Entlassungsdatum

Entscheidend ist also die Änderung von Source{}[Data] zu Value.NativeQuery().