<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Archives des SQL Server - dbi Blog</title>
	<atom:link href="https://www.dbi-services.com/blog/category/sql-server/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.dbi-services.com/blog/category/sql-server/</link>
	<description></description>
	<lastBuildDate>Fri, 15 May 2026 19:45:30 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2025/05/cropped-favicon_512x512px-min-32x32.png</url>
	<title>Archives des SQL Server - dbi Blog</title>
	<link>https://www.dbi-services.com/blog/category/sql-server/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Customer case study &#8211; automating SQL Server TLS Encryption with Ansible and Certificates (Architecture)</title>
		<link>https://www.dbi-services.com/blog/customer-case-study-automating-sql-server-tls-encryption-with-ansible-and-certificates-architecture/</link>
					<comments>https://www.dbi-services.com/blog/customer-case-study-automating-sql-server-tls-encryption-with-ansible-and-certificates-architecture/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Fri, 15 May 2026 19:35:21 +0000</pubDate>
				<category><![CDATA[Ansible]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[SQL Server]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44594</guid>

					<description><![CDATA[<p>When working with SQL Server environments, securing client connections can become an important requirement, especially when TLS encryption must be implemented using certificates. In this context, a customer asked us to develop an Ansible playbook and role to automate the configuration of TLS for SQL Server. The certificates are generated from the customer PKI and [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/customer-case-study-automating-sql-server-tls-encryption-with-ansible-and-certificates-architecture/">Customer case study &#8211; automating SQL Server TLS Encryption with Ansible and Certificates (Architecture)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>When working with SQL Server environments, securing client connections can become an important requirement, especially when TLS encryption must be implemented using certificates. In this context, a customer asked us to develop an Ansible playbook and role to automate the configuration of TLS for SQL Server. The certificates are generated from the customer PKI and provided as PEM files containing the server certificate, the private key, and the certificate chain.</p>



<p>However, some extractions and conversions are required before these certificates can be used on Windows and configured for SQL Server.</p>



<p>Here, the idea is to propose a solution (the architecture) that prepares the certificate, imports it on the SQL Server host, and configures SQL Server to use it.</p>



<p>We will also see how to separate the preparation and activation steps in order to reduce the impact on the SQL Server service.</p>



<p>In this blog post, we will describe the global approach and the Ansible logic used to implement certificate-based TLS encryption for SQL Server.</p>



<h2 class="wp-block-heading" id="h-implementation-logic">Implementation logic</h2>



<p>Before configuring TLS encryption on SQL Server, the first point was to understand the certificate format provided by the customer PKI.</p>



<p>In our case, the generated file is a &lt;machine&gt;.pem file. This file contains the server certificate used for TLS, the private key and the certificate chain with the intermediate and root certificates.</p>



<p>As this format cannot be directly used as-is on the Windows side for SQL Server, some extraction and conversion steps are required.</p>



<p>The general idea is to use the Ansible control node as a working area.</p>



<p>The PEM file is first copied into a temporary folder where the different parts of the certificate are extracted:</p>



<ul class="wp-block-list">
<li>the leaf certificate</li>



<li>the intermediate certificate</li>



<li>the root certificate</li>



<li>the private key</li>
</ul>



<p>These elements are then used to build a PFX file which can be imported on the Windows SQL Server host.</p>



<p>The PFX is installed in the LocalMachine\My certificate store while the intermediate and root certificates are imported into the appropriate Windows certificate stores.</p>



<p>The implementation has been designed around three different execution modes: stage, activate, and full.</p>



<p>The stage mode is used to prepare the certificate without any impact on the SQL Server service. It copies the PEM file, performs the extractions, builds the PFX file, copies it to the managed Windows node and imports the certificates into the Windows certificate stores. No registry change is performed, and the SQL Server service is not restarted. This mode is useful when we want to prepare the server in advance before switching SQL Server to the new certificate.</p>



<p>The activate mode assumes that the certificate is already present on the Windows server. Its role is to configure SQL Server to use the installed certificate and depending on the selected option, restart the SQL Server service or leave the change pending until the next planned reboot.</p>



<p>This can be useful when the certificate activation must be aligned with an existing maintenance window, for example during monthly OS patching.</p>



<p>The full mode executes the complete configuration from end to end. It performs the extraction and conversion steps, imports the certificates, grants the required permissions, configures SQL Server to use the expected certificate, and restarts the SQL Server service only if required. To avoid unnecessary impact, the role relies on the certificate thumbprint. If the expected certificate is already configured, no change is applied and the SQL Server service is not restarted. This behavior is important for idempotency.</p>



<p>For example, if the full mode is executed after an activate mode, nothing should be changed if the certificate is already the correct one. The same logic applies if the playbook is executed by mistake while the certificate has not been renewed.</p>



<p>Another point to manage is the restart of the SQL Server service. SQL Server loads the certificate configuration when the service starts. Therefore, when a new certificate is configured, the change is only effective after a restart of the SQL Server service.</p>



<p>For this reason the role should provide an option to control whether the restart is performed immediately or postponed to the next planned reboot.</p>



<p>We also have to consider DNS aliases. The standard use case is to generate a certificate containing at least the short name and the FQDN of the SQL Server host in the subjectAltName. If DNS aliases are used by client applications, they can also be added to the certificate SAN.</p>



<p>For example:</p>



<pre class="wp-block-code"><code>&#091;alt_names]
DNS.1 = A-WS2022-2.lab.local
DNS.2 = A-WS2022-2</code></pre>



<p>Finally, the customer confirmed that the private key included in the PEM file is not encrypted.</p>



<p>This simplifies the conversion process to PFX, but it also means that the PEM file must be handled carefully during the Ansible execution, especially in temporary folders and during file transfers. With this approach, the role provides a controlled way to prepare, activate, or fully configure TLS encryption for SQL Server while keeping the impact on the SQL Server service under control.</p>



<h2 class="wp-block-heading" id="h-logical-workflow">Logical workflow</h2>



<p>The complete workflow can be represented as follows:</p>



<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" width="672" height="1024" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/I3_Wite_Background-672x1024.jpeg" alt="" class="wp-image-44607" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/I3_Wite_Background-672x1024.jpeg 672w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/I3_Wite_Background-197x300.jpeg 197w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/I3_Wite_Background-768x1169.jpeg 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/I3_Wite_Background-1009x1536.jpeg 1009w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/I3_Wite_Background-1345x2048.jpeg 1345w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/I3_Wite_Background-scaled.jpeg 1681w" sizes="(max-width: 672px) 100vw, 672px" /></figure>



<h2 class="wp-block-heading" id="h-architecture-summary">Architecture summary</h2>



<p>The certificate manipulation is performed on the Ansible control node.</p>



<p>The Windows certificate import and SQL Server configuration are performed on the managed Windows SQL Server host.</p>



<p>This separation is useful because the PEM processing and PFX generation are handled with Linux tools such as OpenSSL while the certificate installation, private key permissions, registry configuration and SQL Server restart are handled through Windows modules and PowerShell. The design also supports a controlled deployment approach.</p>



<p>The certificate can first be staged without service impact then activated later during a maintenance window.</p>



<p>The full mode can be used when the complete implementation must be executed in a single run. The use of the certificate thumbprint is important for idempotency. It allows the role to detect whether SQL Server is already configured with the expected certificate and avoids unnecessary service restarts when no change is required.</p>



<h2 class="wp-block-heading" id="h-remarks">Remarks</h2>



<p>For certain reasons we do not disclose the code of the created role.</p>



<p>Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/customer-case-study-automating-sql-server-tls-encryption-with-ansible-and-certificates-architecture/">Customer case study &#8211; automating SQL Server TLS Encryption with Ansible and Certificates (Architecture)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/customer-case-study-automating-sql-server-tls-encryption-with-ansible-and-certificates-architecture/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>How Row Goal shapes your SQL Server query strategy by hunting for pierogis</title>
		<link>https://www.dbi-services.com/blog/how-row-goal-shapes-your-sql-server-query-strategy-by-hunting-for-pierogis/</link>
					<comments>https://www.dbi-services.com/blog/how-row-goal-shapes-your-sql-server-query-strategy-by-hunting-for-pierogis/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Fri, 15 May 2026 11:34:42 +0000</pubDate>
				<category><![CDATA[Development & Performance]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[Performance]]></category>
		<category><![CDATA[rowgoal]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44511</guid>

					<description><![CDATA[<p>Mastering SQL Server Row Goals and how TOP, EXISTS, and FAST N influence execution plans and how to avoid performance traps in your queries</p>
<p>L’article <a href="https://www.dbi-services.com/blog/how-row-goal-shapes-your-sql-server-query-strategy-by-hunting-for-pierogis/">How Row Goal shapes your SQL Server query strategy by hunting for pierogis</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[<div class="wp-block-image is-style-rounded">
<figure class="aligncenter size-full is-resized"><img decoding="async" width="475" height="433" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-58.png" alt="" class="wp-image-44512" style="aspect-ratio:1.0970763424726049;object-fit:cover;width:243px;height:auto" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-58.png 475w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-58-300x273.png 300w" sizes="(max-width: 475px) 100vw, 475px" /></figure>
</div>


<h2 class="wp-block-heading" id="h-the-wroclaw-connection">The Wroclaw Connection</h2>



<p><a href="https://sqlday.pl/en/sqlday-2026/">SQLDay 2026</a> took place this week, from May 11th to 13th, in Wroclaw. Among the featured speakers was <a href="https://erikdarling.com/" id="https://erikdarling.com/">Erik Darling</a>, who delivered both a main session and a full-day workshop dedicated to SQL Server performance. During his presentations, he emphasized a concept that is not always widely understood, known as the Row Goal.</p>



<p>The purpose of this article is to recap Erik’s key observations and to introduce this topic, which can serve as a powerful lever for query optimization.</p>



<h2 class="wp-block-heading" id="h-a-quick-culinary-detour-and-why-pierogis-matter">A quick culinary detour and why pierogis matter</h2>



<p>In order to understand the explanations below, one key concept must be understood: <strong>the Pierogi</strong>.</p>



<p>&#8220;<em>Pierogi are filled dumplings made from unleavened dough, popular in Polish cuisine and enjoyed worldwide, with various savory and sweet fillings</em>&#8221; <a href="https://en.wikipedia.org/wiki/Pierogi" id="https://en.wikipedia.org/wiki/Pierogi">[1]</a>, <a href="https://www.booths.co.uk/recipe/polish-cheese-potato-pierogi/" id="https://www.booths.co.uk/recipe/polish-cheese-potato-pierogi/">[2]</a>.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large is-resized"><img decoding="async" width="1024" height="576" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-60-1024x576.png" alt="" class="wp-image-44514" style="width:497px;height:auto" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-60-1024x576.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-60-300x169.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-60-768x432.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-60.png 1200w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>
</div>


<p>To be honest, this has nothing to do with our technical topic, but this dish discovered during this trip is so good that I simply had to include it in this blog.</p>



<h2 class="wp-block-heading" id="h-filling-the-aisles-and-designing-our-database">Filling the aisles and designing our database</h2>



<p>In this article, we will use a custom-made database simulating a Polish supermarket selling pierogis. Unfortunately, there aren&#8217;t many left, and the product distribution is not uniform. In fact, pierogis account for <span style="text-decoration: underline">much less than 1%</span> of the supermarket&#8217;s total stock. <br>Here is the script to create the DB, along with its article reference table and inventory:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
USE master;
GO

IF EXISTS (SELECT * FROM sys.databases WHERE name = &#039;PierogiMart&#039;)
    DROP DATABASE PierogiMart;
GO

CREATE DATABASE PierogiMart;
GO

USE PierogiMart;
GO

CREATE TABLE Articles (
    ArticleID INT IDENTITY(1,1) PRIMARY KEY,
    ArticleName VARCHAR(50) NOT NULL,
    Price DECIMAL(10, 2) NOT NULL
);

CREATE TABLE Inventory (
    ReferenceID INT IDENTITY(1,1) PRIMARY KEY,
    ArticleID INT NOT NULL,
    ValidityDate DATETIME NOT NULL,
    Quantity INT NOT NULL,
    CONSTRAINT FK_Article FOREIGN KEY (ArticleID) REFERENCES Articles(ArticleID)
);
GO

INSERT INTO Articles (ArticleName, Price)
VALUES 
(&#039;Pierogi&#039;, 12.50),
(&#039;Pasta&#039;, 8.00),
(&#039;Sandwich&#039;, 6.50),
(&#039;Quiche&#039;, 9.00);
GO

INSERT INTO Inventory (ArticleID, ValidityDate, Quantity)
SELECT TOP 100000 
    2, 
    DATEADD(DAY, ABS(CHECKSUM(NEWID())) % 365, &#039;2025-01-01&#039;), 
    ABS(CHECKSUM(NEWID())) % 100
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

INSERT INTO Inventory (ArticleID, ValidityDate, Quantity)
SELECT TOP 10000 
    3, 
    DATEADD(DAY, ABS(CHECKSUM(NEWID())) % 365, &#039;2025-01-01&#039;), 
    ABS(CHECKSUM(NEWID())) % 100
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

INSERT INTO Inventory (ArticleID, ValidityDate, Quantity)
SELECT TOP 50000 
    4, 
    DATEADD(DAY, ABS(CHECKSUM(NEWID())) % 365, &#039;2025-01-01&#039;), 
    ABS(CHECKSUM(NEWID())) % 100
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

INSERT INTO Inventory (ArticleID, ValidityDate, Quantity)
SELECT TOP 10 
    1, 
    &#039;2026-12-31&#039;, 
    5
FROM sys.all_columns;
GO
</pre></div>


<p>We are also including a few indexes to simulate a real-world use case and to support our queries, ensuring we get realistic execution plans:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE NONCLUSTERED INDEX IDX_INV_QUANT ON &#x5B;dbo].&#x5B;Inventory] (&#x5B;Quantity]) include (ArticleID)

CREATE NONCLUSTERED INDEX IDX_INV_VALIDITY on &#x5B;dbo].&#x5B;Inventory] (&#x5B;ValidityDate]) include (ArticleID)

CREATE NONCLUSTERED INDEX IDX_INV_ART on &#x5B;dbo].&#x5B;Inventory] (ArticleID)
</pre></div>


<h2 class="wp-block-heading" id="h-what-exactly-is-a-row-goal">What exactly is a Row Goal?</h2>



<p>Normally, the SQL Server optimizer seeks to minimize the total cost of processing <strong>all data</strong> for a query. However, if it knows that you only need a specific number of rows (for example, via a <code><a href="https://learn.microsoft.com/en-us/sql/t-sql/queries/top-transact-sql?view=sql-server-ver17">TOP</a></code>, <code><a href="https://www.sqlshack.com/explore-sql-queries-hint-option-fast-n/">FAST(N)</a></code>, or <a href="https://learn.microsoft.com/en-us/sql/t-sql/language-elements/exists-transact-sql?view=sql-server-ver17"><code>EXISTS</code> </a>clause), it changes its strategy.</p>



<p>The <strong>Row Goal</strong> is this specific row target that pushes the optimizer to favor a plan capable of delivering the first few rows as quickly as possible, even if that same plan would be catastrophic for processing the entire table.</p>



<h2 class="wp-block-heading" id="h-top-n-hunting-for-the-best-pierogi">TOP(N): Hunting for the best Pierogi</h2>



<p>To illustrate the definition above, let’s search for the pierogis with the furthest expiration dates. <br><span style="text-decoration: underline">Note that the IDX_INV_VALIDITY index supports this query</span>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT 
    A.ArticleName, 
    A.Price, 
    I.ValidityDate
FROM Articles A
INNER JOIN Inventory I ON A.ArticleID = I.ArticleID
WHERE A.ArticleName = &#039;Pierogi&#039;
order by I.ValidityDate desc;

SELECT top 10
    A.ArticleName, 
    A.Price, 
    I.ValidityDate
FROM Articles A
INNER JOIN Inventory I ON A.ArticleID = I.ArticleID
WHERE A.ArticleName = &#039;Pierogi&#039;
order by I.ValidityDate desc
</pre></div>


<p>The difference between these two queries is that one requests only the first 10 rows, while the other requests all matching rows. However, this simple distinction is not merely applied when displaying the results; this condition is pushed deeper into the execution plan to influence the choice of operators (Nested Loop, Hash Join, Merge Join) further down the tree.</p>



<p>For the first query, here is the resulting plan:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="762" height="342" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-61.png" alt="" class="wp-image-44517" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-61.png 762w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-61-300x135.png 300w" sizes="auto, (max-width: 762px) 100vw, 762px" /></figure>



<p>As we can see, the optimizer chose a <strong>Hash Join</strong> given the volume of data to be joined. A <strong>Hash Match</strong> implies that all the data must be read in order to produce the desired result.</p>



<p>For the second query, here is the execution plan:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="435" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-64-1024x435.png" alt="" class="wp-image-44521" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-64-1024x435.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-64-300x127.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-64-768x326.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-64.png 1239w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>We can see that this time, the optimizer chose a <strong>Nested Loop</strong>, which takes each row from the reference table (Inventory) and joins them with the Articles table. This operation can be very time-consuming if a large number of rows must be processed. However, this is where <strong>EstimateRowsWithoutRowGoal</strong> comes into play. The value of this property is <strong>40&#8217;002.5</strong>; this means that in a case where a subset of rows was <strong>not </strong>specifically required, the optimizer would have estimated the number of rows returned by this operator at that value. We can see, however, that the estimation actually used is <strong>10 rows</strong> for one execution, a value clearly derived from the <code>TOP(10)</code>.</p>



<p>In summary, adding the <code>TOP(10)</code> allowed the optimizer to use a less expensive join for a small amount of data, even though the <code>TOP</code> operator is located at the very end of the execution plan (since a plan is read from right to left).</p>



<h2 class="wp-block-heading" id="h-exists-the-search-for-the-first-match">EXISTS: The search for the first match</h2>



<p>As explained previously, the <code>EXISTS</code> clause has a cardinality of <strong>1</strong> because the very first row meeting the internal condition is enough to validate the case. This triggers a <strong>Row Goal</strong>, as the optimizer must estimate how many rows it will need to read to satisfy (or not) this condition.</p>



<p><em>Note: In cases where the condition is never met, the optimizer’s plan can become highly inefficient; for full details, see Erik Darling&#8217;s blog <a href="https://erikdarling.com/learn-t-sql-with-erik-exists-not-exists-and-row-goals/" id="https://erikdarling.com/learn-t-sql-with-erik-exists-not-exists-and-row-goals/">[here]</a>.</em></p>



<p>We will now observe this behavior with the following query, varying the internal condition of the <code>EXISTS</code> clause by testing one highly selective (discriminant) case and another much less so.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT 
    A.ArticleName, 
    A.Price
FROM Articles A
WHERE not EXISTS (
    SELECT 1/0
    FROM Inventory I 
    WHERE I.ArticleID = A.ArticleID 
    AND I.Quantity &gt; 10 -- vs 98
);
</pre></div>


<p>As you may have noticed, I am looking here for products that maintain a certain quantity for every possible consumption date. My goal, of course, is to avoid depleting the stocks of these excellent Polish pierogis so that everyone can enjoy them!</p>



<p>The case where we want to ensure that all existing quantities for an item are greater than 10 is very difficult to satisfy; based on the statistics available to the optimizer, all items have 10 or more units in stock, except for the pierogis! <br>Since this condition is so widespread, the optimizer knows it will have to scan a large number of rows to find a single case where the condition is <em>not</em> met. This is why it opts for a <strong>Scan</strong>. This behavior is evidenced by the estimated number of rows to be read (<strong>160&#8217;010</strong>, which represents the entire table).</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="517" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-77-1024x517.png" alt="" class="wp-image-44550" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-77-1024x517.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-77-300x152.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-77-768x388.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-77.png 1156w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>On the other hand, for a very restrictive condition <code>(quantity &gt; 98)</code>, the optimizer recognizes that this condition is highly selective. This is why it favors a <strong>Nested Loop</strong>, estimating that only <strong>1&#8217;608 rows</strong> will be necessary to prove the non-existence of the condition.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="411" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-78-1024x411.png" alt="" class="wp-image-44551" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-78-1024x411.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-78-300x120.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-78-768x308.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-78.png 1151w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>In summary, <code>EXISTS</code> forces the optimizer to estimate the number of rows required to find a single occurrence that proves whether a condition is met or not, thereby triggering a local optimization of the execution plan.</p>



<h2 class="wp-block-heading" id="h-option-fast-n-manually-steering-the-engine">OPTION(FAST N): Manually steering the engine</h2>



<p>The <code>OPTION(FAST N)</code> hint allows you to manually introduce the Row Goal concept into a query. This hint does not limit the total number of results returned; instead, it optimizes the execution plan to retrieve the first <strong>N</strong> rows as quickly as possible (potentially at the expense of performance for the remaining rows).</p>



<p>In our example below, we have two identical queries retrieving items with a quantity greater than 10. However, the second one uses an execution plan optimized to return the first row as fast as possible (just to make sure no one steals the last available pierogi from the top of the pile!).</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
select * from Inventory i
where i.Quantity &gt; 10 
order by i.ArticleID

select * from Inventory i
where i.Quantity &gt; 10 
order by i.ArticleID option(fast 1)
</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="575" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-79-1024x575.png" alt="" class="wp-image-44553" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-79-1024x575.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-79-300x168.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-79-768x431.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-79.png 1038w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>Once again, the plans diverge. To retrieve a single row, the <code>IDX_INV_ART</code> index (which already contains sorted <code>ArticleIDs</code>) is used. It performs a <strong>Seek</strong> on the smallest <code>ArticleID</code> to check if it satisfies the condition of having a quantity greater than 10.</p>



<p>However, by enabling <code>SET STATISTICS TIME ON</code>, we can see that the second execution plan is slower than the first<span style="text-decoration: underline"> when returning all requested rows</span> (<strong>250ms vs. 204ms</strong>). While the gap is not massive due to the small table size, the difference is nonetheless observable.</p>



<h2 class="wp-block-heading" id="h-wrapping-up-and-how-to-survive-the-row-goal-gamble">Wrapping up and how to survive the Row Goal gamble</h2>



<p>To conclude, the <strong>Row Goal</strong> is a double-edged sword; brilliant when you only need a quick glimpse of your data, but it can become a real performance trap if the optimizer&#8217;s &#8220;bet&#8221; fails.</p>



<p>Fortunately, if you find that SQL Server is making bad decisions by being too optimistic, you can take back control. By using the hint <code>OPTION (USE HINT ('DISABLE_OPTIMIZER_ROWGOAL'))</code>, you force the optimizer to stop daydreaming and focus on the actual cost of the query. It&#8217;s the ultimate tool to ensure your execution plan doesn&#8217;t end up as messy as a dropped plate of pierogis!</p>
<p>L’article <a href="https://www.dbi-services.com/blog/how-row-goal-shapes-your-sql-server-query-strategy-by-hunting-for-pierogis/">How Row Goal shapes your SQL Server query strategy by hunting for pierogis</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/how-row-goal-shapes-your-sql-server-query-strategy-by-hunting-for-pierogis/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server Snapshot Backup and Restore with Proxmox ZFS &#8211; REST API with SQL Server 2025 (3/3)</title>
		<link>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-rest-api-with-sql-server-2025-3-3/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-rest-api-with-sql-server-2025-3-3/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Thu, 14 May 2026 21:39:18 +0000</pubDate>
				<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[Database management]]></category>
		<category><![CDATA[Operating systems]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[proxmox]]></category>
		<category><![CDATA[ZFS]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44525</guid>

					<description><![CDATA[<p>The proposed architecture consists in adding a small internal REST API on the Proxmox server in order to expose a controlled ZFS snapshot operation. SQL Server 2025 can then call this API through sp_invoke_external_rest_endpoint, instead of running SSH commands directly or relying on an external tool. The role of the API is deliberately limited: it [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-rest-api-with-sql-server-2025-3-3/">SQL Server Snapshot Backup and Restore with Proxmox ZFS &#8211; REST API with SQL Server 2025 (3/3)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>The proposed architecture consists in adding a small internal REST API on the Proxmox server in order to expose a controlled ZFS snapshot operation. SQL Server 2025 can then call this API through sp_invoke_external_rest_endpoint, instead of running SSH commands directly or relying on an external tool.</p>



<p>The role of the API is deliberately limited: it receives a snapshot request, checks that the requested zvol is authorized, and then runs the zfs snapshot command on the Proxmox side. An allowlist is used to restrict the ZFS volumes that can be accessed. This prevents a REST call from being able to manipulate any dataset on the server.</p>



<p>With this approach, we can reproduce a behavior close to what an enterprise storage array provides, but using Proxmox and ZFS. It is important to note that Proxmox does not natively provide the same level of integration as Pure Storage for SQL Server snapshots. Pure Storage provides dedicated mechanisms and integrations. In our case, we need to build a specific orchestration layer. The REST API therefore acts as an adapter between SQL Server, which drives the snapshot backup workflow, and ZFS, which actually performs the storage-level snapshot.</p>



<h2 class="wp-block-heading" id="h-architecture">Architecture</h2>



<p>Here is a global overview of the architecture:</p>



<ul class="wp-block-list">
<li>SQL Server freezes the database I/Os</li>



<li>SQL Server 2025 calls the internal REST API</li>



<li>The REST API validates the request and checks the zvol allowlist</li>



<li>The API triggers the ZFS snapshot on Proxmox</li>



<li>The API returns the snapshot information to SQL Server</li>



<li>SQL Server creates the metadata-only backup</li>



<li>The database I/Os are released</li>
</ul>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="998" height="1024" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-65-998x1024.png" alt="" class="wp-image-44526" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-65-998x1024.png 998w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-65-292x300.png 292w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-65-768x788.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-65-1496x1536.png 1496w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-65-1995x2048.png 1995w" sizes="auto, (max-width: 998px) 100vw, 998px" /></figure>



<h2 class="wp-block-heading">REST API implementation</h2>



<p>Under Proxmox, we install the required packages:</p>



<pre class="wp-block-code"><code>apt update
apt install -y python3-venv sudo openssl</code></pre>



<p>We create a dedicated user:</p>



<pre class="wp-block-code"><code>useradd --system \
&nbsp; --home /opt/sql-zfs-api \
&nbsp; --shell /usr/sbin/nologin \
&nbsp; sqlsnap</code></pre>



<p>We create the following folders:</p>



<pre class="wp-block-code"><code>mkdir -p /opt/sql-zfs-api
mkdir -p /etc/sql-zfs-api</code></pre>



<p>We declare the authorized zvol :</p>



<pre class="wp-block-code"><code>cat &gt;/etc/sql-zfs-api/allowed-zvols &lt;&lt;'EOF'
sqlpool/pve/vm-302-disk-0
EOF</code></pre>



<p>We create a root-only allowlist:</p>



<pre class="wp-block-code"><code>chown root:root /etc/sql-zfs-api/allowed-zvols
chmod 600 /etc/sql-zfs-api/allowed-zvols</code></pre>



<p>Then we create the secured ZFS helper. This script is executed as root through sudo, but it rejects any dataset that is not defined in the allowlist.</p>



<pre class="wp-block-code"><code>cat &gt;/usr/local/sbin/sql-zfs-helper &lt;&lt;'EOF'
#!/usr/bin/env bash
set -euo pipefail

ALLOW_FILE="/etc/sql-zfs-api/allowed-zvols"
LOCK_FILE="/run/sql-zfs-helper.lock"

die() {
  echo "$*" &gt;&amp;2
  exit 1
}

exec 9&gt;"$LOCK_FILE"
flock -n 9 || die "another snapshot operation is already running"

&#091;&#091; -r "$ALLOW_FILE" ]] || die "allowlist not readable: $ALLOW_FILE"

mapfile -t ALLOWED_DATASETS &lt; &lt;(grep -Ev '^\s*(#|$)' "$ALLOW_FILE")

is_allowed() {
  local ds="$1"
  local allowed
  for allowed in "${ALLOWED_DATASETS&#091;@]}"; do
    &#091;&#091; "$ds" == "$allowed" ]] &amp;&amp; return 0
  done
  return 1
}

valid_snapname() {
  &#091;&#091; "$1" =~ ^&#091;A-Za-z0-9_.:-]{1,120}$ ]]
}

ACTION="${1:-}"
shift || true

case "$ACTION" in
  snapshot)
    SNAPNAME="${1:-}"
    shift || true

    valid_snapname "$SNAPNAME" || die "invalid snapshot name: $SNAPNAME"
    &#091;&#091; "$#" -ge 1 ]] || die "no zvol specified"
    &#091;&#091; "$#" -le 8 ]] || die "too many zvols"

    SNAPSHOTS=()

    for DS in "$@"; do
      is_allowed "$DS" || die "dataset not allowed: $DS"
      /sbin/zfs list -H -t volume -o name "$DS" &gt;/dev/null 2&gt;&amp;1 || die "zvol not found: $DS"

      FULLSNAP="${DS}@${SNAPNAME}"

      if /sbin/zfs list -H -t snapshot -o name "$FULLSNAP" &gt;/dev/null 2&gt;&amp;1; then
        die "snapshot already exists: $FULLSNAP"
      fi

      SNAPSHOTS+=("$FULLSNAP")
    done

    /sbin/zfs snapshot "${SNAPSHOTS&#091;@]}"
    /sbin/zfs hold sqlsnap "${SNAPSHOTS&#091;@]}"

    printf '{"status":"ok","snapshots":&#091;'
    SEP=""
    for S in "${SNAPSHOTS&#091;@]}"; do
      printf '%s"%s"' "$SEP" "$S"
      SEP=","
    done
    printf ']}\n'
    ;;

  list)
    /sbin/zfs list -H -t snapshot -o name -r sqlpool | grep '@sql_' || true
    ;;

  *)
    die "usage: sql-zfs-helper snapshot SNAPNAME ZVOL &#091;ZVOL...]"
    ;;
esac
EOF

chown root:root /usr/local/sbin/sql-zfs-helper
chmod 750 /usr/local/sbin/sql-zfs-helper
</code></pre>



<p>We only allow the helper through sudo:</p>



<pre class="wp-block-code"><code>cat &gt;/etc/sudoers.d/sql-zfs-helper &lt;&lt;'EOF'
sqlsnap ALL=(root) NOPASSWD: /usr/local/sbin/sql-zfs-helper *
EOF

chmod 440 /etc/sudoers.d/sql-zfs-helper
visudo -cf /etc/sudoers.d/sql-zfs-helper</code></pre>



<p>We install the FastAPI API:</p>



<pre class="wp-block-code"><code>python3 -m venv /opt/sql-zfs-api/venv
/opt/sql-zfs-api/venv/bin/pip install fastapi "uvicorn&#091;standard]"</code></pre>



<p>We create the application file:</p>



<pre class="wp-block-code"><code>cat &gt;/opt/sql-zfs-api/app.py &lt;&lt;'EOF'
import os
import re
import json
import socket
import secrets
import subprocess
from datetime import datetime, timezone
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

API_KEY = os.environ.get("SQL_ZFS_API_KEY", "")
ALLOW_FILE = "/etc/sql-zfs-api/allowed-zvols"
SNAP_RE = re.compile(r"^&#091;A-Za-z0-9_.:-]{1,120}$")

app = FastAPI(title="SQL ZFS Snapshot API", version="1.0.0")


class SnapshotRequest(BaseModel):
    database: str = Field(..., min_length=1, max_length=128)
    vmid: int = 302
    snapname: str = Field(..., min_length=1, max_length=120)
    zvols: list&#091;str] = Field(..., min_length=1, max_length=8)


def load_allowed_zvols() -&gt; set&#091;str]:
    with open(ALLOW_FILE, "r", encoding="utf-8") as f:
        return {
            line.strip()
            for line in f
            if line.strip() and not line.strip().startswith("#")
        }


def check_api_key(x_sqlsnap_key: str | None) -&gt; None:
    if not API_KEY:
        raise HTTPException(status_code=500, detail="API key not configured")

    if not x_sqlsnap_key:
        raise HTTPException(status_code=401, detail="missing API key")

    if not secrets.compare_digest(x_sqlsnap_key, API_KEY):
        raise HTTPException(status_code=403, detail="invalid API key")


@app.get("/health")
def health():
    return {
        "status": "ok",
        "host": socket.gethostname(),
        "utc": datetime.now(timezone.utc).isoformat(),
    }


@app.post("/v1/sql-zfs/snapshot")
def create_snapshot(
    req: SnapshotRequest,
    x_sqlsnap_key: str | None = Header(default=None, alias="x-sqlsnap-key"),
):
    check_api_key(x_sqlsnap_key)

    if not SNAP_RE.fullmatch(req.snapname):
        raise HTTPException(status_code=400, detail="invalid snapname")

    allowed = load_allowed_zvols()

    for zvol in req.zvols:
        if zvol not in allowed:
            raise HTTPException(status_code=403, detail=f"zvol not allowed: {zvol}")

    cmd = &#091;
        "sudo",
        "/usr/local/sbin/sql-zfs-helper",
        "snapshot",
        req.snapname,
        *req.zvols,
    ]

    try:
        completed = subprocess.run(
            cmd,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=30,
            check=False,
        )
    except subprocess.TimeoutExpired:
        raise HTTPException(status_code=504, detail="zfs snapshot timeout")

    if completed.returncode != 0:
        raise HTTPException(
            status_code=500,
            detail={
                "error": completed.stderr.strip(),
                "stdout": completed.stdout.strip(),
            },
        )

    snapshots = &#091;f"{zvol}@{req.snapname}" for zvol in req.zvols]

    return {
        "status": "ok",
        "database": req.database,
        "vmid": req.vmid,
        "snapname": req.snapname,
        "snapshots": snapshots,
        "media_description": "zfs|" + socket.gethostname() + "|" + ";".join(snapshots),
    }
EOF

chown -R root:root /opt/sql-zfs-api
chmod 755 /opt/sql-zfs-api
chmod 644 /opt/sql-zfs-api/app.py
</code></pre>



<p>We configure and generate the key:</p>



<pre class="wp-block-code"><code>APIKEY="$(openssl rand -hex 32)"
echo "$APIKEY"</code></pre>



<p>We create the environment file:</p>



<pre class="wp-block-code"><code>cat &gt;/etc/sql-zfs-api/sql-zfs-api.env &lt;&lt;EOF
SQL_ZFS_API_KEY=$APIKEY
EOF

chown root:root /etc/sql-zfs-api/sql-zfs-api.env
chmod 600 /etc/sql-zfs-api/sql-zfs-api.env</code></pre>



<p>We need to save the generated key.</p>



<p>Next, we enable HTTPS. SQL Server sp_invoke_external_rest_endpoint calls HTTPS endpoints, and the documentation specifies that only HTTPS endpoints with TLS are supported.</p>



<pre class="wp-block-code"><code>openssl req -x509 -newkey rsa:4096 -sha256 -days 360 -nodes \
  -keyout /etc/sql-zfs-api/tls.key \
  -out /etc/sql-zfs-api/tls.crt \
  -subj "/CN=promox1" \
  -addext "subjectAltName=DNS:promox1,IP:192.168.1.110"

chown root:sqlsnap /etc/sql-zfs-api/tls.key /etc/sql-zfs-api/tls.crt
chmod 640 /etc/sql-zfs-api/tls.key
chmod 644 /etc/sql-zfs-api/tls.crt</code></pre>



<p>The /etc/sql-zfs-api/tls.crt certificate must be imported into the Windows trusted root certification authorities on the SQL Server side. Otherwise, the HTTPS call may fail.</p>



<p>We create the systemd service:</p>



<pre class="wp-block-code"><code>cat &gt;/etc/systemd/system/sql-zfs-api.service &lt;&lt;'EOF'
&#091;Unit]
Description=SQL Server to ZFS Snapshot API
After=network-online.target
Wants=network-online.target

&#091;Service]
User=sqlsnap
Group=sqlsnap
WorkingDirectory=/opt/sql-zfs-api
EnvironmentFile=/etc/sql-zfs-api/sql-zfs-api.env
ExecStart=/opt/sql-zfs-api/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8443 --ssl-keyfile /etc/sql-zfs-api/tls.key --ssl-certfile /etc/sql-zfs-api/tls.crt
Restart=on-failure
RestartSec=3

&#091;Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now sql-zfs-api
systemctl status sql-zfs-api
</code></pre>



<p>We check the status of our API:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="697" height="186" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-67.png" alt="" class="wp-image-44528" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-67.png 697w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-67-300x80.png 300w" sizes="auto, (max-width: 697px) 100vw, 697px" /></figure>



<p>It is possible to call the API in PowerShell using Invoke-RestMethod with PowerShell 7:</p>



<pre class="wp-block-code"><code>$headers = @{
"Content-Type"  = "application/json"
"x-sqlsnap-key" = "MyKey"
}

$body = @{
database = "StackOverflow"
vmid     = 302
snapname = "StackOverflow_test010"
zvols    = @("sqlpool/pve/vm-302-disk-0")
} | ConvertTo-Json -Depth 5

Invoke-RestMethod `
-Uri "https://192.168.1.110:8443/v1/sql-zfs/snapshot" `
-Method Post `
-Headers $headers `
-Body $body `
-ContentType "application/json" `
-SkipCertificateCheck
</code></pre>



<p>This gives:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="833" height="510" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-80.png" alt="" class="wp-image-44590" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-80.png 833w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-80-300x184.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-80-768x470.png 768w" sizes="auto, (max-width: 833px) 100vw, 833px" /></figure>



<h2 class="wp-block-heading" id="h-test-from-sql-server">Test from SQL Server</h2>



<p>A certificate was generated on Proxmox and it needs to be imported on the SQL Server host. In my case, it was located here:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="404" height="79" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-69.png" alt="" class="wp-image-44530" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-69.png 404w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-69-300x59.png 300w" sizes="auto, (max-width: 404px) 100vw, 404px" /></figure>



<p>I then imported it on Windows Server:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="788" height="149" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-70.png" alt="" class="wp-image-44531" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-70.png 788w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-70-300x57.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-70-768x145.png 768w" sizes="auto, (max-width: 788px) 100vw, 788px" /></figure>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="118" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-71-1024x118.png" alt="" class="wp-image-44532" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-71-1024x118.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-71-300x34.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-71-768x88.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-71.png 1384w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>For testing purposes, I created something simple. On the SQL Server side, we can create a database that will be used to store our future stored procedure. This procedure will allow us to interact with the API. In my case, I created a database called dbi_tools:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="244" height="131" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-72.png" alt="" class="wp-image-44533" /></figure>



<p>This database will contain a credential. In our case, the DATABASE SCOPED CREDENTIAL is used to securely store the authentication information required to call the REST API from SQL Server. This allows us, for example, to protect the API key:</p>



<pre class="wp-block-code"><code>USE &#091;dbi_tools]
GO

IF NOT EXISTS (
    SELECT 1
    FROM sys.symmetric_keys
    WHERE name = '##MS_DatabaseMasterKey##'
)
BEGIN
    CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'MyStrongPassword_%99';
END
GO

CREATE DATABASE SCOPED CREDENTIAL &#091;https://192.168.1.110:8443/v1/sql-zfs/snapshot]
WITH
    IDENTITY = 'HTTPEndpointHeaders',
    SECRET = '{"x-sqlsnap-key":"MyAPIKey"}';
GO</code></pre>



<p>We then create a stored procedure to encapsulate the code used to call the API:</p>



<pre class="wp-block-code"><code>USE dbi_tools;
GO

CREATE OR ALTER PROCEDURE dbo.usp_BackupDatabase_WithZfsSnapshot
    @DatabaseName sysname,
    @BackupDirectory nvarchar(4000) = N'D:\Backups\'
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @Url nvarchar(4000) =
        N'https://192.168.1.110:8443/v1/sql-zfs/snapshot';

    DECLARE @Vmid int = 302;

    DECLARE @ZvolsJson nvarchar(max) =
        N'&#091;"sqlpool/pve/vm-302-disk-0"]';

    DECLARE @Stamp varchar(20) =
        REPLACE(REPLACE(CONVERT(varchar(19), SYSUTCDATETIME(), 126), '-', ''), ':', '') + 'Z';

    DECLARE @SafeDbName nvarchar(128) =
        REPLACE(REPLACE(REPLACE(@DatabaseName, N' ', N'_'), N'&#091;', N''), N']', N'');

    DECLARE @SnapName nvarchar(128) =
        CONCAT(N'sql_', @SafeDbName, N'_', @Stamp);

    DECLARE @BackupFile nvarchar(4000) =
        CONCAT(@BackupDirectory, N'\', @SafeDbName, N'_', @Stamp, N'.bkm');

    DECLARE @Payload nvarchar(max) =
    (
        SELECT
            @DatabaseName AS &#091;database],
            @Vmid AS &#091;vmid],
            @SnapName AS &#091;snapname],
            JSON_QUERY(@ZvolsJson) AS &#091;zvols]
        FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
    );

    DECLARE @ReturnCode int;
    DECLARE @Response nvarchar(max);
    DECLARE @SnapshotList nvarchar(max);

    SELECT @SnapshotList =
        STRING_AGG(CONCAT(&#091;value], N'@', @SnapName), N';')
    FROM OPENJSON(@ZvolsJson);

    DECLARE @MediaDescription nvarchar(max) =
        CONCAT(N'zfs|promox1|', @SnapshotList);

    DECLARE @Sql nvarchar(max);

    BEGIN TRY
        SET @Sql =
            N'ALTER DATABASE ' + QUOTENAME(@DatabaseName) +
            N' SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON;';

        EXEC sys.sp_executesql @Sql;

        EXEC @ReturnCode = sys.sp_invoke_external_rest_endpoint
            @url = @Url,
            @method = N'POST',
            @headers = N'{"Content-Type":"application/json","Accept":"application/json"}',
            @payload = @Payload,
            @credential = &#091;https://192.168.1.110:8443/v1/sql-zfs/snapshot],
            @timeout = 30,
            @response = @Response OUTPUT;

        IF @ReturnCode &lt;&gt; 0
        BEGIN
            DECLARE @Err nvarchar(max) =
                CONCAT(N'ZFS snapshot API failed. ReturnCode=', @ReturnCode, N' Response=', @Response);
            THROW 51001, @Err, 1;
        END;

        SET @Sql =
            N'BACKUP DATABASE ' + QUOTENAME(@DatabaseName) + N'
              TO DISK = @BackupFile
              WITH METADATA_ONLY,
                   FORMAT,
                   MEDIANAME = @MediaName,
                   MEDIADESCRIPTION = @MediaDescription,
                   NAME = @BackupName;';

        EXEC sys.sp_executesql
            @Sql,
            N'@BackupFile nvarchar(4000),
              @MediaName nvarchar(128),
              @MediaDescription nvarchar(max),
              @BackupName nvarchar(128)',
            @BackupFile = @BackupFile,
            @MediaName = @SnapName,
            @MediaDescription = @MediaDescription,
            @BackupName = @SnapName;

        SELECT
            @DatabaseName AS database_name,
            @SnapName AS zfs_snapshot_name,
            @SnapshotList AS zfs_snapshots,
            @BackupFile AS metadata_backup_file,
            @MediaDescription AS media_description,
            @Response AS api_response;
    END TRY
    BEGIN CATCH
        IF DATABASEPROPERTYEX(@DatabaseName, 'IsDatabaseSuspendedForSnapshotBackup') = 1
        BEGIN
            SET @Sql =
                N'ALTER DATABASE ' + QUOTENAME(@DatabaseName) +
                N' SET SUSPEND_FOR_SNAPSHOT_BACKUP = OFF;';

            EXEC sys.sp_executesql @Sql;
        END;

        THROW;
    END CATCH
END;
GO
</code></pre>



<p>We then call the stored procedure:</p>



<pre class="wp-block-code"><code>EXEC dbi_tools.dbo.usp_BackupDatabase_WithZfsSnapshot
    @DatabaseName = N'StackOverflow',
    @BackupDirectory = N'D:\Backups\';</code></pre>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="137" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-73-1024x137.png" alt="" class="wp-image-44534" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-73-1024x137.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-73-300x40.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-73-768x102.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-73.png 1432w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>The backup was generated :</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="630" height="149" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-74.png" alt="" class="wp-image-44535" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-74.png 630w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-74-300x71.png 300w" sizes="auto, (max-width: 630px) 100vw, 630px" /></figure>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="777" height="411" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-75.png" alt="" class="wp-image-44536" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-75.png 777w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-75-300x159.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-75-768x406.png 768w" sizes="auto, (max-width: 777px) 100vw, 777px" /></figure>



<h2 class="wp-block-heading" id="h-references">References</h2>



<p><a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers">sp_invoke_external_rest_endpoint</a></p>



<p>Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-rest-api-with-sql-server-2025-3-3/">SQL Server Snapshot Backup and Restore with Proxmox ZFS &#8211; REST API with SQL Server 2025 (3/3)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-rest-api-with-sql-server-2025-3-3/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server Snapshot Backup and Restore with Proxmox ZFS &#8211; Powershell implementation (2/3)</title>
		<link>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-2-3/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-2-3/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Thu, 14 May 2026 21:35:41 +0000</pubDate>
				<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[Database management]]></category>
		<category><![CDATA[Operating systems]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[PowerShell]]></category>
		<category><![CDATA[proxmox]]></category>
		<category><![CDATA[ZFS]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44497</guid>

					<description><![CDATA[<p>In the previous section, we discussed the drawbacks of running the commands manually. Indeed, the manual process was taking too much time and could directly impact the database state while the freeze was occurring. To address this issue, it is possible to automate the solution with PowerShell. The idea is to automate the different operations [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-2-3/">SQL Server Snapshot Backup and Restore with Proxmox ZFS &#8211; Powershell implementation (2/3)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>In the previous section, we discussed the drawbacks of running the commands manually. Indeed, the manual process was taking too much time and could directly impact the database state while the freeze was occurring.</p>



<p>To address this issue, it is possible to automate the solution with PowerShell. The idea is to automate the different operations involved in the snapshot backup and restore process.</p>



<p>We will use two scripts:</p>



<ul class="wp-block-list">
<li>One script to perform the backups and create the snapshots.</li>



<li>One script to perform the restores.</li>
</ul>



<h2 class="wp-block-heading" id="h-backup-process">Backup process</h2>



<p>Here is how the backup process works:</p>



<ul class="wp-block-list">
<li>We connect to the corresponding SQL Server instance.</li>



<li>We change the state of the database using ALTER DATABASE &#8230; SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON. At this point, the I/Os are frozen.</li>



<li>We connect to the hypervisor through SSH.</li>



<li>We create the snapshot.</li>



<li>We back up the database using BACKUP DATABASE &#8230; WITH METADATA_ONLY.</li>



<li>We change the state of the database using ALTER DATABASE &#8230; SET SUSPEND_FOR_SNAPSHOT_BACKUP = OFF. At this point, the I/Os are unfrozen.</li>
</ul>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="627" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-50-1024x627.png" alt="" class="wp-image-44499" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-50-1024x627.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-50-300x184.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-50-768x470.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-50-1536x941.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-50-2048x1254.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<h2 class="wp-block-heading">Powershell implementation (backup)</h2>



<p>Here is the code used to perform the backup:</p>



<pre class="wp-block-code"><code>param(
    &#091;string]$SqlInstance = "VM-WS25-SQL2",
    &#091;string]$Database    = "StackOverflow",
    &#091;string]$BackupDir   = "D:\Backups",
    &#091;string]$PveHost     = "192.168.1.110",
    &#091;string]$PveUser     = "MyUser",
    &#091;string&#091;]]$Zvols     = @("sqlpool/pve/vm-302-disk-0")
)

$Timestamp = Get-Date -Format "yyyyMMddTHHmmss"
$SnapName  = "sql_${Database}_${Timestamp}"

$DbSafe = $Database.Replace("]", "]]")
$BackupFile = Join-Path $BackupDir "${Database}_${Timestamp}.bkm"

$ZfsSnapshots = $Zvols | ForEach-Object { "$_@$SnapName" }
$ZfsSnapshotArgs = $ZfsSnapshots -join " "

$MediaDescription = "zfs|$PveHost|$ZfsSnapshotArgs"

$BackupFileSql = $BackupFile.Replace("'", "''")
$MediaSql = $MediaDescription.Replace("'", "''")

$connString = "Server=$SqlInstance;Database=master;Integrated Security=True;TrustServerCertificate=True;Application Name=ZFS-TSQL-Snapshot;"
$conn = New-Object System.Data.SqlClient.SqlConnection $connString

function Invoke-SqlNonQuery {
    param(&#091;string]$Sql)

    $cmd = $conn.CreateCommand()
    $cmd.CommandTimeout = 0
    $cmd.CommandText = $Sql
    &#091;void]$cmd.ExecuteNonQuery()
}

try {
    $conn.Open()

    Write-Host "Freezing SQL database writes..."
    Invoke-SqlNonQuery "ALTER DATABASE &#091;$DbSafe] SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON;"

    Write-Host "Taking ZFS snapshot on Proxmox..."
    ssh "$PveUser@$PveHost" "zfs snapshot $ZfsSnapshotArgs &amp;&amp; zfs hold sqlsnap $ZfsSnapshotArgs"

    if ($LASTEXITCODE -ne 0) {
        throw "ZFS snapshot failed on $PveHost"
    }

    Write-Host "Writing SQL metadata backup..."

    Invoke-SqlNonQuery @"
BACKUP DATABASE &#091;$DbSafe]
TO DISK = N'$BackupFileSql'
WITH METADATA_ONLY,
     MEDIADESCRIPTION = N'$MediaSql',
     NAME = N'$SnapName';
"@

    Write-Host "Snapshot backup completed:"
    Write-Host "  Snapshot: $ZfsSnapshotArgs"
    Write-Host "  Metadata: $BackupFile"
}
catch {
    Write-Warning $_

    try {
        Write-Warning "Attempting to unfreeze SQL database..."
        Invoke-SqlNonQuery "ALTER DATABASE &#091;$DbSafe] SET SUSPEND_FOR_SNAPSHOT_BACKUP = OFF;"
    }
    catch {
        Write-Warning "Could not unfreeze cleanly. Check SQL Server error log."
    }

    throw
}
finally {
    $conn.Close()
}</code></pre>



<h2 class="wp-block-heading">Restore process</h2>



<p>Here is how the restore process works:</p>



<ul class="wp-block-list">
<li>We connect to the corresponding SQL Server instance.</li>



<li>We take the database offline.</li>



<li>The volume dedicated to the StackOverflow database is taken offline.</li>



<li>We connect to the hypervisor through SSH.</li>



<li>We roll back the corresponding snapshot.</li>



<li>We restore the database using the corresponding backup, which was created at the same time as the snapshot.</li>
</ul>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="627" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-51-1024x627.png" alt="" class="wp-image-44501" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-51-1024x627.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-51-300x184.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-51-768x470.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-51-1536x941.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-51-2048x1254.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<h2 class="wp-block-heading">Powershell implementation (restore)</h2>



<p>Here is the code used to perform the restore:</p>



<pre class="wp-block-code"><code>param(
    &#091;string]$SqlInstance = "VM-WS25-SQL2",
    &#091;string]$Database    = "StackOverflow",
    &#091;string]$BackupFile  = "D:\Backups\StackOverflow_20260514T122642.bkm",
    &#091;string]$SnapName    = "sql_StackOverflow_20260514T122642",
    &#091;string]$PveHost     = "192.168.1.110",
    &#091;string]$PveUser     = "MyUser",
    &#091;string&#091;]]$Zvols     = @("sqlpool/pve/vm-302-disk-0"),
    &#091;string&#091;]]$DatabaseDriveLetters = @("T"),
    &#091;switch]$NoRecovery
)

$ErrorActionPreference = "Stop"

function Assert-SafeName {
    param(
        &#091;string]$Value,
        &#091;string]$Name,
        &#091;string]$Pattern
    )

    if ($Value -notmatch $Pattern) {
        throw "$Name contained not allowed characters : $Value"
    }
}

function Normalize-DriveLetter {
    param(&#091;string]$DriveLetter)

    $letter = $DriveLetter.Trim().TrimEnd(":").ToUpperInvariant()

    if ($letter -notmatch '^&#091;A-Z]$') {
        throw "Drive letter invalid : $DriveLetter"
    }

    return $letter
}

function Get-DiskForDriveLetter {
    param(&#091;string]$DriveLetter)

    $letter = Normalize-DriveLetter $DriveLetter

    $partition = Get-Partition -DriveLetter $letter -ErrorAction Stop
    $disk = $partition | Get-Disk -ErrorAction Stop

    return &#091;pscustomobject]@{
        DriveLetter = $letter
        DiskNumber  = &#091;int]$disk.Number
        IsOffline   = &#091;bool]$disk.IsOffline
        FriendlyName = $disk.FriendlyName
        Size        = $disk.Size
    }
}

function Invoke-SshChecked {
    param(&#091;string]$Command)

    Write-Host "SSH $PveUser@$PveHost :: $Command"

    &amp; ssh "$PveUser@$PveHost" "$Command"

    if ($LASTEXITCODE -ne 0) {
        throw "SSH command failed with code $LASTEXITCODE : $Command"
    }
}

function New-SqlConnection {
    $connString = "Server=$SqlInstance;Database=master;Integrated Security=True;TrustServerCertificate=True;Application Name=ZFS-TSQL-Restore-NoVmRestart;"
    return New-Object System.Data.SqlClient.SqlConnection $connString
}

function Invoke-SqlNonQuery {
    param(&#091;string]$Sql)

    $conn = New-SqlConnection

    try {
        $conn.Open()
        $cmd = $conn.CreateCommand()
        $cmd.CommandTimeout = 0
        $cmd.CommandText = $Sql
        &#091;void]$cmd.ExecuteNonQuery()
    }
    finally {
        $conn.Close()
    }
}

function Invoke-SqlScalar {
    param(&#091;string]$Sql)

    $conn = New-SqlConnection

    try {
        $conn.Open()
        $cmd = $conn.CreateCommand()
        $cmd.CommandTimeout = 0
        $cmd.CommandText = $Sql
        return $cmd.ExecuteScalar()
    }
    finally {
        $conn.Close()
    }
}

function Set-DatabaseDisksOffline {
    param(&#091;object&#091;]]$DiskInfos)

    $offlinedByScript = @()

    foreach ($diskInfo in ($DiskInfos | Sort-Object DiskNumber -Unique)) {
        if ($diskInfo.IsOffline) {
            Write-Host "Disque $($diskInfo.DiskNumber) déjà offline. Lecteur $($diskInfo.DriveLetter):"
            continue
        }

        Write-Host "Taking the Windows disk offline $($diskInfo.DiskNumber), drive $($diskInfo.DriveLetter):"
        Set-Disk -Number $diskInfo.DiskNumber -IsOffline $true

        $offlinedByScript += $diskInfo
    }

    return $offlinedByScript
}

function Set-DatabaseDisksOnline {
    param(&#091;object&#091;]]$DiskInfos)

    foreach ($diskInfo in ($DiskInfos | Sort-Object DiskNumber -Unique)) {
        Write-Host "Bringing the Windows disk back online. $($diskInfo.DiskNumber), drive $($diskInfo.DriveLetter):"
        Set-Disk -Number $diskInfo.DiskNumber -IsOffline $false
    }

    Write-Host "Update-HostStorageCache..."
    Update-HostStorageCache
}

Assert-SafeName -Value $SnapName -Name "SnapName" -Pattern '^&#091;A-Za-z0-9_.:-]{1,160}$'

foreach ($zvol in $Zvols) {
    Assert-SafeName -Value $zvol -Name "Zvol" -Pattern '^&#091;A-Za-z0-9_.:/-]{1,240}$'
}

$DbQuoted = "&#091;" + $Database.Replace("]", "]]") + "]"
$DbLiteral = $Database.Replace("'", "''")
$BackupFileSql = $BackupFile.Replace("'", "''")

$ZfsSnapshots = $Zvols | ForEach-Object { "$_@$SnapName" }
$ZfsSnapshotArgs = ($ZfsSnapshots | ForEach-Object { "'$_'" }) -join " "

$RecoveryOption = if ($NoRecovery) { "NORECOVERY" } else { "RECOVERY" }

$DatabaseDiskInfos = @()
$DisksOfflinedByScript = @()

Write-Host ""
Write-Host "Restore SQL Server from a ZFS snapshot, without restarting the VM"
Write-Host "SQL Instance : $SqlInstance"
Write-Host "Database     : $Database"
Write-Host "BackupFile   : $BackupFile"
Write-Host "DB volumes   : $($DatabaseDriveLetters -join ', ')"
Write-Host "Snapshots    :"
$ZfsSnapshots | ForEach-Object { Write-Host "  $_" }
Write-Host ""

try {
    Write-Host "Checking ZFS snapshots..."
    Invoke-SshChecked "zfs list -H -t snapshot -o name $ZfsSnapshotArgs &gt;/dev/null"

    Write-Host "Identifying Windows disks containing SQL Server files..."
    foreach ($driveLetter in $DatabaseDriveLetters) {
        $diskInfo = Get-DiskForDriveLetter $driveLetter
        $DatabaseDiskInfos += $diskInfo

        Write-Host "Drive $($diskInfo.DriveLetter): -&gt; Windows disk $($diskInfo.DiskNumber) &#091;$($diskInfo.FriendlyName)]"
    }

    $backupDrive = $null
    if ($BackupFile -match '^(&#091;A-Za-z]):\\') {
        $backupDrive = Normalize-DriveLetter $Matches&#091;1]

        try {
            $backupDiskInfo = Get-DiskForDriveLetter $backupDrive
            $targetDiskNumbers = @($DatabaseDiskInfos | ForEach-Object { $_.DiskNumber } | Select-Object -Unique)

            if ($targetDiskNumbers -contains $backupDiskInfo.DiskNumber) {
                throw @"
The backup file $BackupFile is located on drive $backupDrive, which is on the same Windows disk as the SQL Server data volume.
Taking the data disk offline would make the .bkm file inaccessible, and a rollback could also make the .bkm file disappear.
Move the .bkm file to C:, a network share, or another disk that is not rolled back.
"@
            }
        }
        catch {
            throw
        }
    }

    Write-Host "Checking whether the SQL Server database exists..."
    $DbExists = Invoke-SqlScalar "SELECT CASE WHEN DB_ID(N'$DbLiteral') IS NULL THEN 0 ELSE 1 END;"

    if ($DbExists -eq 1) {
        Write-Host "Taking database $Database OFFLINE..."
        Invoke-SqlNonQuery @"
ALTER DATABASE $DbQuoted SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
ALTER DATABASE $DbQuoted SET OFFLINE WITH ROLLBACK IMMEDIATE;
"@
    }
    else {
        Write-Host "Database $Database does not exist in SQL Server. Continuing with disk offline and ZFS rollback."
    }

    Write-Host "Taking Windows disks containing MDF/LDF files offline..."
    $DisksOfflinedByScript = Set-DatabaseDisksOffline -DiskInfos $DatabaseDiskInfos

    Write-Host "Rolling back ZFS snapshot..."
    $RollbackCommands = ($ZfsSnapshots | ForEach-Object { "zfs rollback -r '$_'" }) -join "; "
    Invoke-SshChecked "set -e; $RollbackCommands"

    Write-Host "Bringing Windows disks back online..."
    Set-DatabaseDisksOnline -DiskInfos $DisksOfflinedByScript
    $DisksOfflinedByScript = @()

    Write-Host "Short pause to let Windows and SQL Server detect the restored disk state..."
    Start-Sleep -Seconds 5

    Write-Host "Restoring SQL Server metadata-only backup..."

    $RestoreSql = @"
RESTORE DATABASE $DbQuoted
FROM DISK = N'$BackupFileSql'
WITH METADATA_ONLY,
     REPLACE,
     $RecoveryOption;
"@

    Invoke-SqlNonQuery $RestoreSql

    if (-not $NoRecovery) {
        Write-Host "Setting database back to MULTI_USER..."
        Invoke-SqlNonQuery @"
ALTER DATABASE $DbQuoted SET MULTI_USER;
"@
    }

    Write-Host ""
    Write-Host "Restore completed."
    Write-Host "Database : $Database"
    Write-Host "Snapshot : $SnapName"
    Write-Host "Backup   : $BackupFile"
}
catch {
    Write-Warning "Restore failed: $_"

    if ($DisksOfflinedByScript.Count -gt 0) {
        try {
            Write-Warning "Attempting to bring disks offlined by the script back online..."
            Set-DatabaseDisksOnline -DiskInfos $DisksOfflinedByScript
            $DisksOfflinedByScript = @()
        }
        catch {
            Write-Warning "Unable to automatically bring the disks back online. Check with Get-Disk."
        }
    }

    try {
        $DbExistsAfterError = Invoke-SqlScalar "SELECT CASE WHEN DB_ID(N'$DbLiteral') IS NULL THEN 0 ELSE 1 END;"

        if ($DbExistsAfterError -eq 1 -and -not $NoRecovery) {
            Write-Warning "Attempting to set the database back ONLINE/MULTI_USER..."
            Invoke-SqlNonQuery @"
ALTER DATABASE $DbQuoted SET ONLINE;
ALTER DATABASE $DbQuoted SET MULTI_USER;
"@
        }
    }
    catch {
        Write-Warning "Unable to automatically set the database back ONLINE/MULTI_USER."
    }

    throw
}</code></pre>



<h2 class="wp-block-heading">What does it look like?</h2>



<p>We start the backup process:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="530" height="82" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-52.png" alt="" class="wp-image-44503" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-52.png 530w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-52-300x46.png 300w" sizes="auto, (max-width: 530px) 100vw, 530px" /></figure>



<p>We verify that the snapshot is present:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="750" height="131" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-53.png" alt="" class="wp-image-44504" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-53.png 750w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-53-300x52.png 300w" sizes="auto, (max-width: 750px) 100vw, 750px" /></figure>



<p>We verify that the backup is present:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="601" height="36" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-54.png" alt="" class="wp-image-44505" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-54.png 601w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-54-300x18.png 300w" sizes="auto, (max-width: 601px) 100vw, 601px" /></figure>



<p>We drop the StackOverflow database:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="314" height="301" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-55.png" alt="" class="wp-image-44506" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-55.png 314w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-55-300x288.png 300w" sizes="auto, (max-width: 314px) 100vw, 314px" /></figure>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="310" height="231" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-56.png" alt="" class="wp-image-44507" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-56.png 310w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-56-300x224.png 300w" sizes="auto, (max-width: 310px) 100vw, 310px" /></figure>



<p>We start the restore process:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="951" height="384" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-57.png" alt="" class="wp-image-44508" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-57.png 951w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-57-300x121.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-57-768x310.png 768w" sizes="auto, (max-width: 951px) 100vw, 951px" /></figure>



<p>The database is available again. The restore took only a few seconds for a database of approximately 200 GB.</p>



<h2 class="wp-block-heading">Major drawbacks</h2>



<p>In my case, the solution is executed from the SQL Server itself. Ideally, it should rather be hosted on another server or client machine. We could also imagine running these scripts from a scheduler such as RedDeck, for example.</p>



<p>During the database restore, the database is switched to SINGLE_USER mode. This could be an issue if the applications using the database reconnect very frequently. A better approach would probably be to explicitly terminate the active sessions using the KILL command.</p>



<p>We have also not yet covered the use of a REST API.</p>



<p>Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-2-3/">SQL Server Snapshot Backup and Restore with Proxmox ZFS &#8211; Powershell implementation (2/3)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs-2-3/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server Snapshot Backup and Restore with Proxmox ZFS (1/3)</title>
		<link>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Thu, 14 May 2026 21:26:03 +0000</pubDate>
				<category><![CDATA[Database management]]></category>
		<category><![CDATA[Hardware & Storage]]></category>
		<category><![CDATA[Operating systems]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[proxmox]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[ZFS]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44439</guid>

					<description><![CDATA[<p>We are currently working with clients on migrations to SQL Server 2022 and SQL Server 2025. During a discussion with one client, we reviewed some of the benefits introduced in the latest SQL Server 2022 and 2025 releases. Among the available features, starting with SQL Server 2022, we have: Starting with SQL Server 2025: The [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs/">SQL Server Snapshot Backup and Restore with Proxmox ZFS (1/3)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>We are currently working with clients on migrations to SQL Server 2022 and SQL Server 2025. During a discussion with one client, we reviewed some of the benefits introduced in the latest SQL Server 2022 and 2025 releases.</p>



<p>Among the available features, starting with SQL Server 2022, we have:</p>



<ul class="wp-block-list">
<li>T-SQL snapshot backup : <a href="https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/create-a-transact-sql-snapshot-backup?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/create-a-transact-sql-snapshot-backup?view=sql-server-ver17</a></li>
</ul>



<p>Starting with SQL Server 2025:</p>



<ul class="wp-block-list">
<li>REST API Call through sp_invoke_external_rest_endpoint : <a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers">https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers</a></li>
</ul>



<p>The customer’s environment consists of a very large number of instances, some of which host very large SQL Server databases. In this customer’s case, we are referring to a database of approximately 6–7 TB, configured for high availability using Always On Availability Groups. For this database, backups take around two hours, and restores take slightly longer.</p>



<p>In addition, the customer has a Pure Storage array.</p>



<p>We explained to the customer that it is possible to use certain SQL Server 2025 features together with their Pure Storage array to perform snapshots and restores very quickly.</p>



<p>In summary, the process consists of performing the following operations:</p>



<ul class="wp-block-list">
<li>Change the database state to suspend writes.</li>



<li>Create the snapshot using the storage system.</li>



<li>Perform a backup using the BACKUP DATABASE MyDB WITH METADATA_ONLY command to indicate that a snapshot has been taken.</li>
</ul>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="231" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-36-1024x231.png" alt="" class="wp-image-44440" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-36-1024x231.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-36-300x68.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-36-768x173.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-36.png 1130w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>Reference: <a href="https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/create-a-transact-sql-snapshot-backup?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/create-a-transact-sql-snapshot-backup?view=sql-server-ver17</a></p>



<p>However, the customer raised several interesting questions, which, reading between the lines, can be summarized as follows:</p>



<ul class="wp-block-list">
<li>Can this also be applied to PostgreSQL?</li>



<li>Are we dependent on Pure Storage to achieve this?</li>
</ul>



<p>Several articles have been published about the implementation of this process between SQL Server and Pure Storage including the following one:</p>



<ul class="wp-block-list">
<li><a href="https://www.nocentino.com/posts/2025-05-19-t-sql-rest-api-integration-in-sql-server-2025-streamlining-t-sql-snapshot-backups">https://www.nocentino.com/posts/2025-05-19-t-sql-rest-api-integration-in-sql-server-2025-streamlining-t-sql-snapshot-backups</a></li>
</ul>



<p>In my opinion, it is possible to reproduce this operating model with other systems. In my case, we will use Proxmox and ZFS.</p>



<h2 class="wp-block-heading" id="h-context-and-environment">Context and environment</h2>



<p>ZFS pool provides fast, storage-level, copy-on-write snapshots with minimal space overhead. This makes it well suited for SQL Server snapshot backups, where the database writes are briefly suspended while the underlying virtual disk is captured. ZFS also allows precise rollback or cloning of a snapshot, which is useful for both restore testing and recovery scenarios.</p>



<p>On Proxmox, it integrates naturally with VM disks, making it a practical alternative to enterprise storage snapshot platforms.</p>



<p>The environment consists of a server and two disks: one disk used to store the VMs, and a 1 TB Samsung T7 disk that will be used to create our ZFS pool.</p>



<h2 class="wp-block-heading" id="h-proxmox-setup">Proxmox Setup</h2>



<p>We identity the path of the related volume (Samsung T7) :</p>



<pre class="wp-block-code"><code>for d in /dev/disk/by-id/*; do
&nbsp; &#091; "$(readlink -f "$d")" = "/dev/sda" ] &amp;&amp; echo "$d"
done</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="447" height="62" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-32.png" alt="" class="wp-image-44441" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-32.png 447w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-32-300x42.png 300w" sizes="auto, (max-width: 447px) 100vw, 447px" /></figure>



<p>We create the pool. Everything stored in the disk will be erased :</p>



<pre class="wp-block-code"><code>DISK="/dev/disk/by-id/usb-Samsung_PSSD_T7_S6TWNJ0T300328F-0:0"

wipefs -a "$DISK"
sgdisk --zap-all "$DISK"
zpool create \
&nbsp; -o ashift=12 \
&nbsp; -o autotrim=on \
&nbsp; -O compression=lz4 \
&nbsp; -O atime=off \
&nbsp; -O xattr=sa \
&nbsp; -O acltype=posixacl \
&nbsp; -m /mnt/sqlpool \
&nbsp; sqlpool "$DISK"</code></pre>



<p>Then we create a Proxmox dataset for the VM disks:</p>



<pre class="wp-block-code"><code>zfs create sqlpool/pve</code></pre>



<p>We add it to proxmox:</p>



<pre class="wp-block-code"><code>pvesm add zfspool sql-zfs \
  --pool sqlpool/pve \
  --content images,rootdir \
  --sparse 1</code></pre>



<p>We check the pool:</p>



<pre class="wp-block-code"><code>zpool status sqlpool

zfs list

pvesm status
pool: sqlpool
state: ONLINE

config:
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; NAME&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; STATE&nbsp;&nbsp;&nbsp;&nbsp; READ WRITE CKSUM
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;sqlpool&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ONLINE&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 0&nbsp;&nbsp;&nbsp;&nbsp; 0&nbsp;&nbsp;&nbsp;&nbsp; 0
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;usb-Samsung_PSSD_T7_S6TWNJ0T300328F-0:0&nbsp;   ONLINE&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 0&nbsp;&nbsp;&nbsp;&nbsp; 0&nbsp;&nbsp;&nbsp;&nbsp; 0

errors: No known data errors

NAME&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; USED&nbsp; AVAIL&nbsp; REFER&nbsp; MOUNTPOINT
sqlpool&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 636K&nbsp;&nbsp; 899G&nbsp;&nbsp;&nbsp; 96K&nbsp; /mnt/sqlpool
sqlpool/pve&nbsp;&nbsp;&nbsp; 96K&nbsp;&nbsp; 899G&nbsp;&nbsp;&nbsp; 96K&nbsp; /mnt/sqlpool/pve

Name&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Type&nbsp;&nbsp;&nbsp;&nbsp; Status&nbsp;&nbsp;&nbsp;&nbsp; Total (KiB)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Used (KiB) Available (KiB)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; %
local&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; dir&nbsp;&nbsp;&nbsp;&nbsp; active&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 98497780&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 42429080&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 51019152&nbsp;&nbsp; 43.08%
local-lvm&nbsp;&nbsp;&nbsp;&nbsp; lvmthin&nbsp;&nbsp;&nbsp;&nbsp; active&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 3746553856&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 285112748&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 3461441107&nbsp;&nbsp;&nbsp; 7.61%
sql-zfs&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; zfspool&nbsp;&nbsp;&nbsp;&nbsp; active&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 942931428&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 96&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 942931332&nbsp;&nbsp;&nbsp; 0.00%</code></pre>



<p>My VM ID is 302 and we have to add the virtual disk into the ZFS pool:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="251" height="203" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-37.png" alt="" class="wp-image-44446" /></figure>



<pre class="wp-block-code"><code>VMID=302
qm set "$VMID" --agent enabled=1
qm set "$VMID" --scsihw virtio-scsi-single
qm set "$VMID" --scsi1 sql-zfs:700,cache=none,discard=on,iothread=1,ssd=1</code></pre>



<p>Be carefull to the scsi ID. You may overwrite a used volume.</p>



<h2 class="wp-block-heading" id="h-what-does-it-look-like">What does it look like ?</h2>



<p>Once the pool created we have something like this :</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="375" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-40-1024x375.png" alt="" class="wp-image-44450" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-40-1024x375.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-40-300x110.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-40-768x281.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-40-1536x562.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-40.png 1614w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>On the virtual machine side, I have 3 disks :</p>



<ul class="wp-block-list">
<li>1 for my virtual machine (for Windows Server)</li>



<li>1 for SQL Server</li>



<li>1 linked to the ZFS pool to store the user database (the StackOverflow database)</li>
</ul>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="795" height="300" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-41.png" alt="" class="wp-image-44453" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-41.png 795w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-41-300x113.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-41-768x290.png 768w" sizes="auto, (max-width: 795px) 100vw, 795px" /></figure>



<h2 class="wp-block-heading" id="h-sql-server-setup">SQL Server setup</h2>



<p>The virtual machine used for the tests runs with:</p>



<ul class="wp-block-list">
<li>Windows Server 2025 Standard Edition</li>



<li>SQL Server 2025 Enterprise Developer Edition</li>
</ul>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="346" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-47-1024x346.png" alt="" class="wp-image-44457" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-47-1024x346.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-47-300x101.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-47-768x259.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-47.png 1403w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>The mounted zvol is represented by the Databases (T:) volume. Most of the files related to the SQL Server installation are stored on the SQL (D:) volume while the StackOverflow database is located on the Databases (T:) volume.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="251" height="273" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-35.png" alt="" class="wp-image-44444" /></figure>



<h2 class="wp-block-heading" id="h-manual-process-flow-snapshot">Manual process flow (snapshot)</h2>



<p>Here is how we will proceed to create a snapshot and then restore the database:</p>



<ul class="wp-block-list">
<li>ALTER DATABASE [StackOverflow] SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON;</li>



<li>Create the snapshot using the zfs snapshot command.</li>



<li>Run BACKUP DATABASE [StackOverflow] &#8230; WITH METADATA_ONLY.</li>
</ul>



<p>To avoid confusion and to be able to link the snapshot to the backup, we will include the snapshot name in the MEDIADESCRIPTION clause.</p>



<p>Here are the corresponding commands to create the snapshot:</p>



<pre class="wp-block-code"><code>ALTER DATABASE &#091;StackOverflow] SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON;</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="979" height="150" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-34.png" alt="" class="wp-image-44443" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-34.png 979w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-34-300x46.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-34-768x118.png 768w" sizes="auto, (max-width: 979px) 100vw, 979px" /></figure>



<p>We perform the snapshot:</p>



<pre class="wp-block-code"><code>zfs snapshot sqlpool/pve/vm-302-disk-0@StackOverflow_11052026_235500</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="646" height="18" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-39.png" alt="" class="wp-image-44447" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-39.png 646w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-39-300x8.png 300w" sizes="auto, (max-width: 646px) 100vw, 646px" /></figure>



<p>In the same session as the ALTER DATABASE command, we perform a backup:</p>



<pre class="wp-block-code"><code>BACKUP DATABASE &#091;StackOverflow]
TO DISK = N'D:\Backups\StackOverflow_11052026_235500.bkm'
WITH METADATA_ONLY, MEDIADESCRIPTION = N'zfs|proxmox1|sqlpool/pve/vm-302-disk-0@StackOverflow_11052026_235500';</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="888" height="229" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-38.png" alt="" class="wp-image-44448" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-38.png 888w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-38-300x77.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-38-768x198.png 768w" sizes="auto, (max-width: 888px) 100vw, 888px" /></figure>



<p>The error log shows the following:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="57" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-31-1024x57.png" alt="" class="wp-image-44445" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-31-1024x57.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-31-300x17.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-31-768x43.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-31.png 1385w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>We verify that the snapshot has been successfully created:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="745" height="118" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-42.png" alt="" class="wp-image-44449" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-42.png 745w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-42-300x48.png 300w" sizes="auto, (max-width: 745px) 100vw, 745px" /></figure>



<p>And the SQL backup :</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="633" height="148" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-33.png" alt="" class="wp-image-44442" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-33.png 633w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-33-300x70.png 300w" sizes="auto, (max-width: 633px) 100vw, 633px" /></figure>



<h2 class="wp-block-heading" id="h-manual-process-flow-restore">Manual process flow (restore)</h2>



<p>We now need to be able to restore the database. Before doing so, we can delete a few tables to verify that the database has been restored as expected. We deleted most of the tables, leaving only three:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="227" height="331" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-45.png" alt="" class="wp-image-44454" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-45.png 227w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-45-206x300.png 206w" sizes="auto, (max-width: 227px) 100vw, 227px" /></figure>



<p>To perform the restore, we will follow these steps:</p>



<ul class="wp-block-list">
<li>Take the database offline.</li>



<li>Rollback the snapshot using the zfs rollback command.</li>



<li>Restore the database using the SQL backup created earlier.</li>
</ul>



<p>This is done using the following commands:</p>



<pre class="wp-block-code"><code>ALTER DATABASE &#091;StackOverflow] SET OFFLINE WITH ROLLBACK IMMEDIATE;</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="979" height="203" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-46.png" alt="" class="wp-image-44455" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-46.png 979w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-46-300x62.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-46-768x159.png 768w" sizes="auto, (max-width: 979px) 100vw, 979px" /></figure>



<p>Snapshot restore:</p>



<pre class="wp-block-code"><code>zfs rollback -r sqlpool/pve/vm-302-disk-0@StackOverflow_13052026_230000</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="661" height="18" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-44.png" alt="" class="wp-image-44452" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-44.png 661w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-44-300x8.png 300w" sizes="auto, (max-width: 661px) 100vw, 661px" /></figure>



<p>Database restore:</p>



<pre class="wp-block-code"><code>RESTORE DATABASE &#091;StackOverflow]
FROM DISK = N'D:\Backups\StackOverflow_13052026_230000.bkm'
WITH METADATA_ONLY, REPLACE, NORECOVERY;

RESTORE DATABASE &#091;StackOverflow] WITH RECOVERY;</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="918" height="445" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-43.png" alt="" class="wp-image-44451" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-43.png 918w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-43-300x145.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-43-768x372.png 768w" sizes="auto, (max-width: 918px) 100vw, 918px" /></figure>



<p><strong>We were able to restore our database in less than one second, even though it is approximately 207 GB in size.</strong></p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="147" height="40" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-48.png" alt="" class="wp-image-44456" /></figure>



<h2 class="wp-block-heading" id="h-major-drawbacks">Major drawbacks</h2>



<p>The process is manual, and we need to switch between running commands in SQL Server and performing the snapshot/restore operations in Proxmox. This freezes the database for a certain amount of time. During that period, connected applications could generate errors or timeouts.</p>



<p>The solution to this problem would be to automate the process using PowerShell, for example.</p>



<h2 class="wp-block-heading" id="h-what-was-not-covered-in-this-section">What was not covered in this section</h2>



<p>While writing this blog post, I omitted two points:</p>



<ul class="wp-block-list">
<li>When the database is deleted, it is necessary to take the volume dedicated to the StackOverflow database, Databases (D:), offline. Indeed, When you run a DROP DATABASE, SQL Server deletes the files from disk, and the database no longer exists. Then, if you perform a zfs rollback while Windows still sees the disk as online, you are effectively changing the disk “under Windows feet” Windows may keep the previous NTFS state cached: an empty directory, MFT information, file handles, volume metadata, and so on. As a result, the ZFS rollback may have completed successfully, but Windows does not properly refresh its view of the disk.</li>



<li>We did not make any calls to a REST API. Indeed, this functionality does not exist in my case, but it is possible to implement it.</li>
</ul>



<p>Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs/">SQL Server Snapshot Backup and Restore with Proxmox ZFS (1/3)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-snapshot-backup-and-restore-with-proxmox-zfs/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>When a Python driver configuration issue may cause blocking in SQL Server</title>
		<link>https://www.dbi-services.com/blog/when-a-python-driver-configuration-issue-may-cause-blocking-in-sql-server/</link>
					<comments>https://www.dbi-services.com/blog/when-a-python-driver-configuration-issue-may-cause-blocking-in-sql-server/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Thu, 14 May 2026 21:21:48 +0000</pubDate>
				<category><![CDATA[Database management]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[blocking]]></category>
		<category><![CDATA[Performance]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44425</guid>

					<description><![CDATA[<p>One of our clients encountered blocking during their daily data load. The process loads several million rows and then performs an ALTER TABLE &#8230; SWITCH operation into a partitioned table. This operation usually takes some time, but in this case it became blocked. Context Initially, I did not have access to much information. The only [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/when-a-python-driver-configuration-issue-may-cause-blocking-in-sql-server/">When a Python driver configuration issue may cause blocking in SQL Server</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>One of our clients encountered blocking during their daily data load. The process loads several million rows and then performs an ALTER TABLE &#8230; SWITCH operation into a partitioned table. This operation usually takes some time, but in this case it became blocked.</p>



<h2 class="wp-block-heading" id="h-context">Context</h2>



<p>Initially, I did not have access to much information. The only element I received from the client was a extract of the output from the sp_WhoIsActive procedure.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="63" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-28-1024x63.png" alt="" class="wp-image-44428" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-28-1024x63.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-28-300x18.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-28-768x47.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-28-1536x94.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-28.png 1880w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<h2 class="wp-block-heading" id="h-initial-analysis">Initial analysis</h2>



<p>Based on this extract, we were able to perform a first-level analysis:</p>



<p>A Python session executed a query against MyTable without applying a date filter. On a table containing approximately 244 million rows, this prevented proper partition elimination and forced SQL Server to read a much broader data set than necessary. Queries against partitioned tables only benefit from partition elimination when the predicate references the partitioning column without such a predicate, SQL Server may have to search or scan all partitions.</p>



<p>The Python session eventually became sleeping but remained with open_tran_count = 1. This is a typical sign of an unclosed transaction on the client side: autocommit disabled, cursor not closed, result set not fully consumed, connection returned to the pool without a rollback…</p>



<p>Session 146 then attempted to perform the partition TRUNCATE/SWITCH operation. However, TRUNCATE TABLE requires a schema modification lock, Sch-M, and ALTER TABLE &#8230; SWITCH also requires a Sch-M lock on both the source and target tables.</p>



<p>This Sch-M lock could not be acquired while session 167 was still referencing the object. SQL Server documents Sch-M as the lock required to modify the schema and to ensure that no other session is referencing the object. Once the Sch-M request from session 146 was queued, new read queries were also blocked behind it. Even NOLOCK would not avoid this issue: queries still acquire Sch-S locks during compilation and execution, and Sch-S and Sch-M locks block each other.</p>



<h2 class="wp-block-heading" id="h-second-analysis">Second analysis</h2>



<p>After some time, we were able to access the client’s environment. Query Store was enabled on the affected database, and an Extended Events session was configured on the SQL Server instance to track blocking.</p>



<p>Querying the Extended Events session provided detailed information about the blocking events that occurred, and we were able to identify the specific blocking issue reported by the client.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="118" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-27-1024x118.png" alt="" class="wp-image-44427" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-27-1024x118.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-27-300x35.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-27-768x89.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-27.png 1205w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>By looking more closely at this blocking issue, we found the following:</p>



<pre class="wp-block-code"><code>EXEC &#091;STAGING_DB].&#091;ETL].&#091;sp_ETL_Exec]
&nbsp;&nbsp;&nbsp; @ETL_StepIKs_List = '&#091;"Exec-&#091;TARGET_DB].dbo.&#091;SP_Load_TargetTable]"]',
&nbsp;&nbsp;&nbsp; @StartAsJob = 0

Which is blocked by:

WITH position AS
(
&nbsp;&nbsp;&nbsp; SELECT ...
&nbsp;&nbsp;&nbsp; FROM &#091;SOURCE_DB].&#091;SCHEMA_NAME].&#091;LARGE_PARTITIONED_TABLE]
&nbsp;&nbsp;&nbsp; ...
)

&lt;blocking-process&gt;
&nbsp;&nbsp;&nbsp; spid="167"
&nbsp;&nbsp;&nbsp; status="sleeping"
&nbsp;&nbsp;&nbsp; trancount="1"
&nbsp;&nbsp;&nbsp; clientapp="python&#091;version]"
&nbsp;&nbsp;&nbsp; hostname="client-host-..."
&nbsp;&nbsp;&nbsp; loginname="user_account"
&nbsp;&nbsp;&nbsp; inputbuf="... WITH position AS ..."
&lt;/blocking-process&gt;</code></pre>



<p>However, the blocking report highlights an important point: session 167 was no longer actively executing the query at the time the report was captured:</p>



<ul class="wp-block-list">
<li>status = sleeping</li>



<li>trancount = 1</li>
</ul>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="85" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-26-1024x85.png" alt="" class="wp-image-44429" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-26-1024x85.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-26-300x25.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-26-768x64.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-26.png 1119w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>However, by correlating this information with Query Store data, we were able to obtain additional details. By retrieving the corresponding query, we could better understand what was happening.</p>



<p>The blocking report also showed that session 146 was requesting a Sch-M lock, meaning a Schema Modification Lock. This is a strong lock required for operations such as TRUNCATE, ALTER TABLE, and partition SWITCH.</p>



<p>According to the data, session 146 waited for more than two hours, approximately 7,770,160 ms.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="773" height="75" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-29.png" alt="" class="wp-image-44426" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-29.png 773w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-29-300x29.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-29-768x75.png 768w" sizes="auto, (max-width: 773px) 100vw, 773px" /></figure>



<p>However, by correlating this information with Query Store data, we were able to obtain additional details. Specifically, by retrieving the query:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="613" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-30-1024x613.png" alt="" class="wp-image-44430" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-30-1024x613.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-30-300x180.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-30-768x460.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-30.png 1086w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>It was executed 30 times during the following time interval: 05-05-2026 from 2:00 PM to 3:00 PM. The average execution time was 49.1 seconds, with a maximum execution time of approximately 57 seconds. This represents a total of around 24 minutes of cumulative execution time over a one-hour period.</p>



<p>Based on this data, the issue was therefore not caused by the performance of the query itself, but rather by the state of session 167. Indeed, the session left a transaction open, with an open_tran_count of 1, thereby locking the corresponding objects and preventing other sessions from accessing them.</p>



<h2 class="wp-block-heading" id="h-how-is-it-related-to-python-driver-configuration">How is it related to Python driver configuration?</h2>



<p>The observed blocking can likely be explained by a misconfiguration or misuse of the Python driver used to access SQL Server. The root session was a Python connection in a sleeping state, but with trancount = 1, which indicates that a transaction was still open even though the query was no longer actively running.</p>



<p>In this situation, SQL Server may continue to hold transaction-related locks even if the application appears to have completed its work.</p>



<p>If the Python driver was running with autocommit = 0, each SELECT statement could implicitly start a transaction that then had to be explicitly closed with a commit or rollback. If the cursor was not closed properly, the result set was not fully consumed, or a rollback was not issued before returning the connection to the pool, the session could remain open on the SQL Server side. This residual transaction likely prevented the related ETL process from acquiring the Sch-M lock required for the TRUNCATE or partition SWITCH operation.</p>



<p>As a result the ETL session was not the initial root cause. It was waiting for a lock held by an idle Python connection.</p>



<p>Next queries then accumulated behind the pending Sch-M lock request, creating the impression of a global outage.</p>



<p>Switching to autocommit = 1 significantly reduces this risk, because read operations are no longer tied to an open transaction by default. Finally, preventing parallel pipeline execution helps avoid amplifying the issue when a job is delayed.</p>



<p>Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/when-a-python-driver-configuration-issue-may-cause-blocking-in-sql-server/">When a Python driver configuration issue may cause blocking in SQL Server</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/when-a-python-driver-configuration-issue-may-cause-blocking-in-sql-server/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQLDay 2026 Workshops Overview</title>
		<link>https://www.dbi-services.com/blog/sqlday-2026-workshops-overview/</link>
					<comments>https://www.dbi-services.com/blog/sqlday-2026-workshops-overview/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Thu, 14 May 2026 21:16:03 +0000</pubDate>
				<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Development & Performance]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[Microsoft]]></category>
		<category><![CDATA[sqlday]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44349</guid>

					<description><![CDATA[<p>SQLDay 2026 offers a full-day workshop program on 11 May 2026, before the main conference scheduled for 12–13 May 2026 in Wrocław, with onsite and online participation options depending on the session. The workshops cover several areas of the modern data platform: advanced BI, AI and MLOps, SQL performance tuning, PostgreSQL adoption, and Microsoft Fabric [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sqlday-2026-workshops-overview/">SQLDay 2026 Workshops Overview</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-full"><a href="https://sqlday.pl/en/sqlday-2026/#workshop"><img loading="lazy" decoding="async" width="758" height="200" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-18.png" alt="" class="wp-image-44350" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-18.png 758w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/05/image-18-300x79.png 300w" sizes="auto, (max-width: 758px) 100vw, 758px" /></a></figure>



<p>SQLDay 2026 offers a full-day workshop program on 11 May 2026, before the main conference scheduled for 12–13 May 2026 in Wrocław, with onsite and online participation options depending on the session. The workshops cover several areas of the modern data platform: advanced BI, AI and MLOps, SQL performance tuning, PostgreSQL adoption, and Microsoft Fabric automation.</p>



<h2 class="wp-block-heading" id="h-dax-beyond-the-basics">DAX – Beyond the Basics</h2>



<p>This workshop is designed for Power BI users who already know the basics of DAX but now need to solve more complex business problems. The focus is on moving from simple reports to reusable, efficient and business-oriented DAX patterns.</p>



<p>Participants will work on practical scenarios such as advanced slicer logic, hierarchical calculations, year-to-date reporting, visual calculations, cumulative totals, ranking, and relative-period analysis. The main objective is to extend the participant’s DAX toolbox and help them write expressions that are both more powerful and better performing</p>



<h2 class="wp-block-heading">AI in Databricks: Training, Deployment and Monitoring</h2>



<p>This Polish onsite workshop covers the complete lifecycle of machine learning models in Databricks. The goal is to show how to move from data preparation to training, automation, deployment and monitoring in a production-oriented environment.</p>



<p>The workshop focuses on the practical implementation of MLOps using Databricks and MLflow. Topics include AI/ML architecture, data pipelines, feature engineering, model training, deep learning, CI/CD, orchestration, model versioning and monitoring. It is mainly targeted at engineers, data scientists and architects who are already working with machine learning or planning to start.</p>



<h2 class="wp-block-heading">Building an Intelligent Agent in One Day with Copilot Studio</h2>



<p>This Polish onsite workshop focuses on building conversational and autonomous agents with Microsoft Copilot Studio. The format is highly practical, with most of the time dedicated to hands-on exercises.</p>



<p>Participants will build agents that automate business processes, use multimodal data, generate data-driven answers and connect to enterprise data sources. The workshop also covers Dataverse grounding, flows, plugins, actions, autonomous triggers, Responsible AI, moderation and access control. It is a good fit for participants who want to understand how Copilot Studio can be used beyond simple chatbot scenarios.</p>



<h2 class="wp-block-heading">Advanced T-SQL Triage: The Art of Fixing Terrible Code</h2>



<p>This workshop is focused on real-world SQL Server troubleshooting and refactoring. The starting point is familiar to many DBAs and developers: complex stored procedures, poor query patterns, blocking data modifications, bad use of CTEs, problematic window functions, indexed views, dynamic SQL, user-defined functions and execution plans that are difficult to understand.</p>



<p>The objective is not only to identify what is slow, but also to understand why it is slow and how to rewrite it properly. This session is especially relevant for people who regularly inherit problematic T-SQL code and need a structured way to fix it without guessing.</p>



<h2 class="wp-block-heading">Adding PostgreSQL to your SQL Server Skill Set</h2>



<p>This workshop targets SQL Server professionals who need to add PostgreSQL to their technical scope. The context is clear: many organizations are adding PostgreSQL without immediately replacing SQL Server, which creates a need for people who understand both platforms.</p>



<p>The workshop compares the two database engines, explains the areas of overlap, and highlights the differences that can make PostgreSQL challenging for SQL Server users. It also covers tooling, documentation, cloud options and practical resources to support the learning path.</p>



<h2 class="wp-block-heading">Automating Your Microsoft Fabric Data Platform: From Blueprint to Reality</h2>



<p>This onsite hands-on lab focuses on automation in Microsoft Fabric. The goal is to help participants automate the full lifecycle of a Fabric data platform, from design and setup to deployment and documentation.</p>



<p>The workshop covers platform setup using code and configuration scripts, metadata-driven ingestion, semantic model foundations, CI/CD with GitHub and Azure DevOps, Fabric CLI, REST APIs and the fabric-cicd Python library. The expected outcome is a more robust, scalable and repeatable approach to building Fabric solutions, with less manual work and lower operational risk.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p>The SQLDay 2026 workshop program is clearly oriented toward practical implementation. Each session addresses a common challenge faced by data teams: improving analytical models, industrializing AI, fixing complex SQL code, extending SQL Server skills to PostgreSQL, or automating a modern Microsoft Fabric platform.</p>



<p>The common thread is operational efficiency. These workshops are not only about learning features; they are about applying them in real environments, with constraints such as performance, maintainability, automation, governance and production readiness.</p>



<p>Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui.</a></p>



<p>References :</p>



<figure class="wp-block-embed is-type-wp-embed is-provider-sqlday wp-block-embed-sqlday"><div class="wp-block-embed__wrapper">
<blockquote class="wp-embedded-content" data-secret="PtjsmWOzXi"><a href="https://sqlday.pl/en/sqlday-2026/">SQLDay 2026</a></blockquote><iframe loading="lazy" class="wp-embedded-content" sandbox="allow-scripts" security="restricted"  title="&#8222;SQLDay 2026&#8221; &#8212; SQLDay" src="https://sqlday.pl/en/sqlday-2026/embed/#?secret=Aw5HUIvZfS#?secret=PtjsmWOzXi" data-secret="PtjsmWOzXi" width="500" height="282" frameborder="0" marginwidth="0" marginheight="0" scrolling="no"></iframe>
</div></figure>



<p></p>



<p></p>



<p></p>



<p></p>
<p>L’article <a href="https://www.dbi-services.com/blog/sqlday-2026-workshops-overview/">SQLDay 2026 Workshops Overview</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sqlday-2026-workshops-overview/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server 2025 In-Memory: New Cleanup Features &#038; SQLBits 2026 Insights</title>
		<link>https://www.dbi-services.com/blog/in-memory-tables-in-sql-server-2025-and-sqlbits-2026/</link>
					<comments>https://www.dbi-services.com/blog/in-memory-tables-in-sql-server-2025-and-sqlbits-2026/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Sun, 10 May 2026 19:29:35 +0000</pubDate>
				<category><![CDATA[Development & Performance]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[inmemory]]></category>
		<category><![CDATA[SQLBits]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=44077</guid>

					<description><![CDATA[<p>SQL Server 2025 finally allows dropping In-Memory filegroups, a breakthrough analyzed here alongside expert performance insights from SQLBits 2026.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/in-memory-tables-in-sql-server-2025-and-sqlbits-2026/">SQL Server 2025 In-Memory: New Cleanup Features &amp; SQLBits 2026 Insights</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large is-style-rounded"><img loading="lazy" decoding="async" width="1024" height="384" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-18-1024x384.png" alt="" class="wp-image-44196" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-18-1024x384.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-18-300x113.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-18-768x288.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-18-1536x576.png 1536w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-18.png 1920w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>Summer is already around the corner, but it&#8217;s not too late for some spring cleaning!<br>If you manage SQL Server databases with <a href="https://learn.microsoft.com/en-us/sql/relational-databases/in-memory-oltp/introduction-to-memory-optimized-tables?view=sql-server-ver17" id="https://learn.microsoft.com/en-us/sql/relational-databases/in-memory-oltp/introduction-to-memory-optimized-tables?view=sql-server-ver17">In-Memory tables</a>, you may have already tried to delete a <code>MEMORY_OPTIMIZED_DATA</code> file or <code>FILEGROUP</code>, only to find that SQL Server simply won&#8217;t let you. <br>This limitation has existed since the debut of In-Memory with SQL Server 2014, and the only workaround until now was to recreate the database from scratch.<br>It is with SQL Server 2025 that Microsoft finally lifts this restriction. In this article, we will analyze these behavioral differences before and after this version. <br>To conclude, we will draw on the key points presented by <a href="https://www.linkedin.com/in/thodoris-katsimanis-057a5711a/" id="https://www.linkedin.com/in/thodoris-katsimanis-057a5711a/">Thodoris Katsimanis</a>, DBA Team Technology Manager at Kaizen Gaming, during his session at <a href="https://sqlbits.com/" id="https://sqlbits.com/">SQLBits 2026</a> on In-Memory tables, in order to summarize the challenges and benefits this feature can bring to production.</p>



<h2 class="wp-block-heading" id="h-the-legacy-struggle-in-memory-limitations-from-2014-to-2022">The Legacy Struggle: In-Memory Limitations from 2014 to 2022</h2>



<p>To demonstrate this difference in behavior, we will create a database under SQL Server 2022 with an In-Memory table loaded with data, and then attempt to delete the associated files and FILEGROUP:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
DECLARE @DataPath NVARCHAR(512) = &#039;&lt;YOUR_DATA_FOLDER&gt;&#039;;
DECLARE @LogPath  NVARCHAR(512) = &#039;&lt;YOUR_LOG_FOLDER&gt;&#039;;
DECLARE @SQL      NVARCHAR(MAX);

SET @SQL = N&#039;
CREATE DATABASE TestInMemory
ON PRIMARY (
    NAME = TestInMemory_data,
    FILENAME = &#039;&#039;&#039; + @DataPath + N&#039;TestInMemory.mdf&#039;&#039;
),
FILEGROUP XTP_FG CONTAINS MEMORY_OPTIMIZED_DATA (
    NAME = TestInMemory_XTP,
    FILENAME = &#039;&#039;&#039; + @DataPath + N&#039;TestInMemory_XTP&#039;&#039;
)
LOG ON (
    NAME = TestInMemory_log,
    FILENAME = &#039;&#039;&#039; + @LogPath + N&#039;TestInMemory_log.ldf&#039;&#039;
);&#039;;

EXEC sp_executesql @SQL;

USE TestInMemory;
GO

CREATE TABLE dbo.TestTable
(
    ID    INT          NOT NULL,
    Val   NVARCHAR(50) NOT NULL,
    CONSTRAINT PK_TestTable PRIMARY KEY NONCLUSTERED HASH (ID)
        WITH (BUCKET_COUNT = 1024)
)
WITH (MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_AND_DATA);
GO

INSERT INTO dbo.TestTable VALUES (1, &#039;Hello&#039;), (2, &#039;World&#039;);
GO
</pre></div>


<p>Once our table is loaded (to ensure the table exists and is not just metadata), we can then delete it:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
DROP TABLE dbo.TestTable;
GO

SELECT name, type_desc 
FROM sys.tables 
WHERE is_memory_optimized = 1;
</pre></div>


<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="491" height="188" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-12.png" alt="" class="wp-image-44079" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-12.png 491w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-12-300x115.png 300w" sizes="auto, (max-width: 491px) 100vw, 491px" /></figure>



<p>The verification clearly shows that no more In-Memory objects exist. We can therefore proceed with the famous cleanup of the files linked to the table we deleted:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
ALTER DATABASE TestInMemory 
REMOVE FILE TestInMemory_XTP;
GO

ALTER DATABASE TestInMemory
REMOVE FILEGROUP XTP_FG;
GO
</pre></div>


<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="735" height="355" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-11.png" alt="" class="wp-image-44078" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-11.png 735w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-11-300x145.png 300w" sizes="auto, (max-width: 735px) 100vw, 735px" /></figure>



<p>And here is the famous error, impossible to bypass it and sort things out.<br><strong>Note:</strong> This cleanup challenge specifically affects tables using DURABILITY = SCHEMA_AND_DATA, as they are the only ones where data persists within physical files on disk.</p>



<h2 class="wp-block-heading" id="h-sql-server-2025-breaking-the-in-memory-cleanup-barrier">SQL Server 2025: Breaking the In-Memory Cleanup Barrier</h2>



<p>SQL Server 2025 does not just lift the restriction: it also introduces a new DMV, <code><a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-db-xtp-undeploy-status?view=sql-server-ver17">sys.dm_db_xtp_undeploy_status</a></code>, which exposes the precise reason why the deletion is not yet possible. <br>By querying it at the same stage as our previous example, here is what it returns:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
USE TestInMemory;
GO

SELECT
    deployment_state,
    deployment_state_desc,
    undeploy_lsn,
    start_of_log_lsn
FROM sys.dm_db_xtp_undeploy_status;
GO
</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="285" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-13-1024x285.png" alt="" class="wp-image-44080" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-13-1024x285.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-13-300x84.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-13-768x214.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-13.png 1055w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p>Now we have a clear reason: the <code>start_of_log_lsn</code> is too old, which prevents SQL Server from releasing the FILEGROUP. To resolve this, the LSNs must be advanced. A FULL backup is first required to initialize the backup chain, followed by a LOG backup to effectively advance the position in the logs:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
CHECKPOINT;
GO

BACKUP DATABASE TestInMemory TO DISK = &#039;NUL&#039;;
GO

BACKUP LOG TestInMemory TO DISK = &#039;NUL&#039;;
GO
</pre></div>


<p>Once the LOG backup is executed and the LSNs are sufficiently advanced, the files can finally be deleted. The <code>sys.dm_db_xtp_undeploy_status</code> view confirms that the XTP engine is no longer deployed and that the cleanup has been successfully performed.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="710" height="98" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-14.png" alt="" class="wp-image-44081" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-14.png 710w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-14-300x41.png 300w" sizes="auto, (max-width: 710px) 100vw, 710px" /></figure>



<p>SQL Server 2025 not only introduces the ability to purge empty files that were linked to an In-Memory object but also the ability to troubleshoot their deletion!</p>



<h2 class="wp-block-heading" id="h-from-milliseconds-to-microseconds-thodoris-katsimanis-at-sqlbits-2026">From Milliseconds to Microseconds: Thodoris Katsimanis at SQLBits 2026</h2>



<p>To conclude this article, let&#8217;s look back at the key points covered by Thodoris Katsimanis during his session at SQLBits 2026, entitled <em>&#8220;Revolutionizing Database Performance: Deep Dive into SQL InMemory Technology&#8221;</em>.<br>His context is particularly telling: at Kaizen Gaming, SQL Server databases handle thousands of transactions per second in real time, in an environment where every millisecond has a direct impact on the user experience. It is precisely in this type of workload that In-Memory tables reveal their full potential.</p>



<h3 class="wp-block-heading" id="h-eliminating-page-contention-with-latch-free-architecture">Eliminating Page Contention with Latch-Free Architecture</h3>



<p>The presentation exposed limitations of the SQL Server engine: in a high-performance system, latches on disk pages (<code><a href="https://www.sqlskills.com/help/waits/pagelatch_ex/" id="https://www.sqlskills.com/help/waits/pagelatch_ex/">PAGELATCH_EX</a></code>) create bottlenecks that can lead to Thread Pool exhaustion. The In-Memory architecture solves this problem at its root via a latch-free structure. By relying on optimistic concurrency control and multi-versioning (<a href="https://www.geeksforgeeks.org/dbms/what-is-multi-version-concurrency-control-mvcc-in-dbms/" id="https://www.geeksforgeeks.org/dbms/what-is-multi-version-concurrency-control-mvcc-in-dbms/">MVCC</a>), SQL Server no longer waits for locks. Each row has a Begin-Timestamp and an End-Timestamp, allowing transactions to read the valid version of the data without blocking writes.</p>



<h3 class="wp-block-heading" id="h-maximizing-performance-the-crucial-choice-between-hash-and-bw-tree">Maximizing Performance: The Crucial Choice between Hash and BW-Tree</h3>



<p>The choice between a Hash index and a Nonclustered index is crucial. The Hash index is perfectly suited for Point Lookups (searches on an exact value): it points directly to the memory address via a hash function. Conversely, the Nonclustered index relies on a BW-Tree structure, which is essential for range scans and sorting, where Hash is of little use. To learn more about indexes for In-Memory tables, check the <a href="https://learn.microsoft.com/en-us/sql/relational-databases/in-memory-oltp/indexes-for-memory-optimized-tables?view=sql-server-ver17" id="https://learn.microsoft.com/en-us/sql/relational-databases/in-memory-oltp/indexes-for-memory-optimized-tables?view=sql-server-ver17">Microsoft&#8217;s documentation</a>.</p>



<h3 class="wp-block-heading" id="h-the-critical-impact-of-bucket-count-misconfiguration">The Critical Impact of BUCKET_COUNT Misconfiguration</h3>



<p>As Thodoris points out, the success of a Hash index relies on tuning the <code>BUCKET_COUNT</code>. This parameter defines the number of entry points in the index. If it is too low, the system generates collision chains: multiple values end up in the same bucket, forcing the engine to scan a linked list, which degrades performance. If it is too high, it consumes memory unnecessarily. Thodoris also highlights a crucial observation: using a Nonclustered index for an equality search can consume significantly more memory than a properly sized Hash index.</p>



<h2 class="wp-block-heading" id="h-final-thoughts-embracing-the-in-memory-revolution">Final Thoughts: Embracing the In-Memory Revolution</h2>



<p>SQL Server 2025 finally lifts a limitation that has hampered the lifecycle management of In-Memory databases for over ten years. Being able to cleanly delete associated files and FILEGROUPs, understanding why the engine blocks this operation thanks to <code>sys.dm_db_xtp_undeploy_status</code>, and having a clear procedure to remedy it: this is concrete progress for everyone operating this technology in production.</p>



<p>Thodoris Katsimanis&#8217;s session at SQLBits 2026 reminds us that the care given to maintenance and monitoring matters just as much as the initial design. In-Memory tables are not a simple performance lever to be activated and forgotten: they require a mastery of their internal mechanisms, thread management to eliminate contention, and rigorous sizing of the <code>BUCKET_COUNT</code>. As he summarizes: the millisecond is no longer a sufficient unit of measurement. In-Memory OLTP aims for the microsecond and in hyper-transactional environments, that is precisely what makes the difference.</p>



<p></p>
<p>L’article <a href="https://www.dbi-services.com/blog/in-memory-tables-in-sql-server-2025-and-sqlbits-2026/">SQL Server 2025 In-Memory: New Cleanup Features &amp; SQLBits 2026 Insights</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/in-memory-tables-in-sql-server-2025-and-sqlbits-2026/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Scaling SSRS Migrations: Multi-Threaded Automation for PBIRS 2025</title>
		<link>https://www.dbi-services.com/blog/scaling-ssrs-migrations-multi-threaded-automation-for-pbirs-2025/</link>
					<comments>https://www.dbi-services.com/blog/scaling-ssrs-migrations-multi-threaded-automation-for-pbirs-2025/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Tue, 07 Apr 2026 12:58:14 +0000</pubDate>
				<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[PBIRS]]></category>
		<category><![CDATA[PowerShell]]></category>
		<category><![CDATA[SQLServer]]></category>
		<category><![CDATA[SSRS]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=43802</guid>

					<description><![CDATA[<p>Migrate SSRS to PBIRS 2025: a PowerShell ETL to automate extraction, XML patching, and parallelized deployment.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/scaling-ssrs-migrations-multi-threaded-automation-for-pbirs-2025/">Scaling SSRS Migrations: Multi-Threaded Automation for PBIRS 2025</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>Modernizing a reporting platform is a pivotal milestone for any BI infrastructure. Whether it’s a standard upgrade or a forced transition to <strong><a href="https://learn.microsoft.com/en-us/power-bi/report-server/download-powerbi-report-server">Power BI Report Server (PBIRS)</a></strong> following the decommissioning of SSRS in SQL Server 2025, the operation is critical. For the purposes of our lab, we will use an SSRS 2017 source, but the logic remains universal: regardless of the original version, the goal is to ensure the continuity of your decision-making services without sacrificing your mental health in the process.</p>



<p>As my colleague Amine Haloui explained in <a href="https://www.dbi-services.com/blog/sql-server-2025-retirement-of-sql-server-reporting-services-ssrs/" id="https://www.dbi-services.com/blog/sql-server-2025-retirement-of-sql-server-reporting-services-ssrs/">a recent blog post</a>, several strategies exist for migrating an instance. The &#8220;Lift and Shift&#8221; method (restoring the <code>ReportServer</code> database onto a new instance) is often the most attractive on paper. However, the reality on the ground can be more temperamental.</p>



<p>In some production environments, the target PBIRS instance already exists, hosts its own content, or follows specific configurations that prohibit simply overwriting its underlying <code>ReportServer</code> database. Therefore, we are proceeding here on the premise of a selective and granular migration: we must inject the SSRS catalog into an active PBIRS environment without burning everything to the ground in the process.</p>



<p>When faced with inventories exceeding hundreds or even thousands of reports (RDL), folders, and datasources, a manual approach via the web interface is not an option and automation becomes a necessity.</p>



<p>This article analyzes a systematic approach based on the <code>ReportingServicesTools</code> PowerShell module. The objective is to provide a robust methodology to extract your catalog and redeploy it intelligently, while managing the necessary reconfigurations along the way.</p>



<h2 class="wp-block-heading" id="h-phase-1-smart-dumping-building-the-local-staging-area">Phase 1: Smart Dumping – Building the Local Staging Area</h2>



<p>To migrate cleanly, objects must first be isolated. The idea is not to blindly vacuum everything, but to target the critical folders of your SSRS instance and transform them into flat files (.rdl and .rds) within a local staging area. If your SSRS instance contains specific object types, the scripts can easily be adapted to include them as well.</p>



<p>This is where the power of the <strong><a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/new-webserviceproxy?view=powershell-5.1">SOAP Proxy</a></strong> comes into play. Rather than multiplying slow HTTP calls, we use the native service interface to list and extract our components:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
$sourceUrl  = &quot;http://your-ssrs-server/ReportServer&quot;
$exportRoot = &quot;H:\Migration_Dump&quot;

$proxySource = New-RsWebServiceProxy -ReportServerUri $sourceUrl
</pre></div>


<p>In a production environment, SSRS folders are often a messy mix of reports, data sources, images, and sometimes obsolete semantic models. To maintain total control over what we export, we isolate the filtering logic.</p>



<p>This <code>Get-AllItemsByType</code> function allows us to retrieve only what truly matters to us, based on the <strong>TypeName</strong> and <strong>file extension</strong> returned by the API.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
function Get-AllItemsByType {
    param(
        &#x5B;string]$CurrentPath,
        $Proxy,
        &#x5B;string]$TypeName 
    )
    try {
        return $Proxy.ListChildren($CurrentPath, $true) | Where-Object { $_.TypeName -eq $TypeName }
    } catch {
        Write-Host &quot;  &#x5B;!] Error on $CurrentPath : $($_.Exception.Message)&quot; -ForegroundColor Red
        return $null
    }
}
</pre></div>


<p>This mapping between the file type and its extension must be defined upfront in a dictionary:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
$extensionMap = @{
    &quot;Report&quot; = &quot;.rdl&quot;
    &quot;DataSource&quot; = &quot;.rds&quot;
}
</pre></div>


<p>A crucial point in extracting SSRS objects is preserving their context. To ensure a seamless import into PBIRS 2025, we must recreate the exact folder hierarchy of the source server locally.</p>



<p>The trick lies in transforming the SSRS path (formatted as <code>/Folder/SubFolder/Report</code>) into a valid Windows path, while simultaneously handling the extension mapping (<code>.rdl</code> for reports, <code>.rds</code> for DataSources).</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
function Export-SsrsItems {
    param(
        &#x5B;string]$RootPath,
        $Proxy,
        &#x5B;string]$TypeName,
        &#x5B;string]$ExportRoot
    )

    $items = Get-AllItemsByType -CurrentPath $RootPath -Proxy $Proxy -TypeName $TypeName

    foreach ($item in $items) {
        $relativeItemPath = $item.Path.TrimStart(&#039;/&#039;).Replace(&quot;/&quot;, &quot;\&quot;)
        $localFilePath    = Join-Path $ExportRoot $relativeItemPath
        $localDirectory   = Split-Path -Path $localFilePath -Parent

        if (-not (Test-Path $localDirectory)) {
            New-Item -ItemType Directory -Path $localDirectory -Force | Out-Null
        }

        Out-RsCatalogItem -Path $item.Path -Destination $localDirectory -Proxy $Proxy
    }
}
</pre></div>


<p>By doing this, your <code>H:\Migration_Dump</code> becomes the exact mirror of your SSRS portal. This structural rigor is what will allow us, in the next step, to remap our data sources without having to hunt down which report belongs to which department.</p>



<div class="wp-block-columns no-bottom-margin is-layout-flex wp-container-core-columns-is-layout-9d6595d7 wp-block-columns-is-layout-flex">
<div class="wp-block-column is-layout-flow wp-block-column-is-layout-flow">
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="284" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-7-1024x284.png" alt="" class="wp-image-43818" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-7-1024x284.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-7-300x83.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-7-768x213.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-7.png 1145w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>
</div>



<div class="wp-block-column is-layout-flow wp-block-column-is-layout-flow">
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="887" height="255" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-6.png" alt="" class="wp-image-43817" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-6.png 887w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-6-300x86.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-6-768x221.png 768w" sizes="auto, (max-width: 887px) 100vw, 887px" /></figure>
</div>
</div>



<p>Finally, we define the folders we wish to export along with the document types they contain (since a migration is often the perfect time for a bit of spring cleaning):</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
$exportTasks = @(
    @{ Path = &quot;/Migration_Source_2&quot;; Types = @(&quot;Report&quot;) },
    @{ Path = &quot;/Data Sources&quot;;  Types = @(&quot;DataSource&quot;) }
)

Write-Host &quot;--- Selective Export Started ---&quot; -ForegroundColor Cyan

foreach ($task in $exportTasks) {
    foreach ($typeName in $task.Types) {
        $ext = $extensionMap&#x5B;$typeName]
        Export-SsrsItems `
            -RootPath   $task.Path `
            -Proxy      $proxySource `
            -TypeName   $typeName `
            -Extension  $ext `
            -ExportRoot $exportRoot
    }
}
</pre></div>

<div class="wp-block-image">
<figure class="aligncenter size-full"><img loading="lazy" decoding="async" width="682" height="385" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-1.png" alt="" class="wp-image-43807" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-1.png 682w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-1-300x169.png 300w" sizes="auto, (max-width: 682px) 100vw, 682px" /></figure>
</div>


<h2 class="wp-block-heading" id="h-phase-2-data-source-patching-mass-xml-transformation">Phase 2: Data Source Patching – Mass XML Transformation</h2>



<p>Once the extraction is complete, you have a local mirror of your source instance, but the data sources still point to the legacy infrastructure.</p>



<p>Instead of manually fixing each connection after the import (the best way to miss half of them), we will apply an automated transformation directly to our local XML files. This allows us to update connection strings in bulk before a single report even hits the target server.</p>



<p>The idea is simple: use PowerShell to inject the new SQL instance wherever necessary, ensuring a functional deployment from the very first second:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
$allDataSources = Get-ChildItem -Path $exportRoot -Filter &quot;*.rds&quot; -Recurse

Write-Host &quot;&#x5B;&gt;] Datasources updated in : $exportRoot&quot; -ForegroundColor Yellow

foreach ($dsFile in $allDataSources) {
    &#x5B;xml]$xmlContent = Get-Content $dsFile.FullName
 
    $node = $xmlContent.SelectSingleNode(&quot;//ConnectString&quot;)
    
    if ($null -ne $node) {
        $oldValue = $node.&quot;#text&quot; 
        if ($null -eq $oldValue) { $oldValue = $node.InnerText }

        $newValue = $oldValue -replace &quot;OLD_REPORTING_INSTANCE&quot;, &quot;NEW_REPORTING_INSTANCE&quot;
        
        if ($oldValue -ne $newValue) {
            $node.InnerText = $newValue
            $xmlContent.Save($dsFile.FullName)
            Write-Host &quot;  &#x5B;v] ConnectString updated in : $($dsFile.Name)&quot; -ForegroundColor Green
        }
    } else {
        Write-Host &quot;  &#x5B;!] ConnectString not found in file $($dsFile.Name)&quot; -ForegroundColor Red
    }
}
</pre></div>


<p>Moreover, since we are interacting directly with the file’s XML structure, this logic isn&#8217;t limited to connection strings: you can apply the same principle to automate changes for any XML property, from timeouts to provider names.</p>



<h2 class="wp-block-heading" id="h-phase-3-mass-deployment-rebuilding-the-reporting-portal">Phase 3: Mass Deployment – Rebuilding the Reporting Portal</h2>



<p>At this stage, the operation is purely mechanical. We once again leverage the <strong>ReportingServicesTools</strong> module to recreate the folder structure and upload the <code>.rds</code> and <code>.rdl</code> files. By following this specific order, PBIRS will automatically restore the links between your reports and their newly patched data sources.</p>



<p>It is worth noting that the script allows for importing into a specific root folder (defined by the <code>$destroot</code> variable). This is particularly useful if you want to isolate the migrated assets into a dedicated directory, such as <code>SSRS_Folder</code> to keep them distinct from the existing hierarchy. Furthermore, this script is designed with safety in mind: it cannot overwrite or delete anything. If a report with the same name already exists in the same location, the <code>-Overwrite:$false</code> argument prevents replacement, ensuring that the import process never destroys existing content.</p>



<p>Here is the final block to complete your migration:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
$destUrl   = &quot;http://your-pbirs-server/ReportServer&quot; 
$localDump = &quot;H:\Migration_Dump&quot;
$destRoot  = &quot;/&quot; #Start import in the root folder
$proxyDest = New-RsWebServiceProxy -ReportServerUri $destUrl

$extensionMap = @{
    &quot;Report&quot;     = &quot;.rdl&quot;
    &quot;DataSource&quot; = &quot;.rds&quot;
}

function Ensure-RsFolderBruteForce {
    param($fullFolderPath, $Proxy)
    $parts = $fullFolderPath.Split(&#039;/&#039;) | Where-Object { $_ -ne &#039;&#039; }
    $currentPath = &#039;&#039;
    
    foreach ($part in $parts) {
        $parent = if ($currentPath -eq &#039;&#039;) { &quot;/&quot; } else { $currentPath }
        $target = if ($currentPath -eq &#039;&#039;) { &quot;/$part&quot; } else { &quot;$currentPath/$part&quot; }
        
        try {
            $Proxy.CreateFolder($part, $parent, $null) | Out-Null
            Write-Host &quot;  &#x5B;DIR] Created : $target&quot; -ForegroundColor Cyan
        } catch {
            if ($_.Exception.Message -match &quot;AlreadyExists&quot;) {
                # Folder already exists but we continue
            } else {
                Write-Host &quot;  &#x5B;!] Error for folder $target : $($_.Exception.Message)&quot; -ForegroundColor Red
            }
        }
        $currentPath = $target
    }
}

function Import-SsrsItem {
    param(
        &#x5B;System.IO.FileInfo]$File,
        &#x5B;string]$LocalDump,
        &#x5B;string]$DestRoot,
        $Proxy
    )

    $relativeDir       = $File.DirectoryName.Replace($LocalDump, &#039;&#039;).Replace(&quot;\&quot;, &quot;/&quot;)
    $targetFolderPath  = ($DestRoot + $relativeDir).Replace(&quot;//&quot;, &quot;/&quot;)
    $fullItemPath      = ($targetFolderPath + &quot;/&quot; + $File.BaseName).Replace(&quot;//&quot;, &quot;/&quot;)

    Ensure-RsFolderBruteForce -fullFolderPath $targetFolderPath -Proxy $Proxy

    try {
        Write-RsCatalogItem -Path $File.FullName -Destination $targetFolderPath -Proxy $Proxy -Overwrite:$false
        Write-Host &quot;  &#x5B;DONE] Imported: $fullItemPath&quot; -ForegroundColor Green
    }
    catch {
        if ($_.Exception.Message -match &quot;already exists&quot;) {
            Write-Host &quot;  &#x5B;SKIP] Already created : $fullItemPath&quot; -ForegroundColor Gray
        } else {
            Write-Host &quot;  &#x5B;FAIL] Error $fullItemPath : $($_.Exception.Message)&quot; -ForegroundColor Red
        }
    }
}

$importOrder = @(&quot;DataSource&quot;, &quot;Report&quot;)

foreach ($typeName in $importOrder) {
    $extension = $extensionMap&#x5B;$typeName]
    Write-Host &quot;`n&#x5B;PASS] Import of object with type : $typeName ($extension)&quot; -ForegroundColor Magenta
    
    $filesToImport = Get-ChildItem -Path $localDump -Filter &quot;*$extension&quot; -Recurse

    if ($filesToImport.Count -eq 0) {
        Write-Host &quot;  &#x5B;i] No file with $extension found.&quot; -ForegroundColor Gray
        continue
    }

    foreach ($file in $filesToImport) {
        Import-SsrsItem -File $file -LocalDump $localDump -DestRoot $destRoot -Proxy $proxyDest
    }
}

Write-Host &quot;`nImport done!&quot; -ForegroundColor Green
</pre></div>


<p>Importing via SOAP is more resource-intensive than extraction, as the server must validate every piece of metadata and physically recreate the path for each report. On large volumes, this stage can become a bottleneck (averaging ~1 second per report).</p>



<p>To overcome this, we can parallelize the import by folder, creating multiple background jobs running on separate threads. Here is the general skeleton to implement this multi-threaded approach:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
$maxJobs = 5 

foreach ($file in $filesToImport) {
    while ((Get-Job -State Running).Count -ge $maxJobs) {
        Start-Sleep -Milliseconds 500
    }

    Start-Job -Name &quot;Import_$($file.Name)&quot; -ScriptBlock {
        param($f, $url, $targetPath)

        $Proxy = New-RsWebServiceProxy -ReportServerUri $url
        
        try {
            Write-RsCatalogItem -Path $f.FullName -Destination $targetPath -Proxy $Proxy -Overwrite:$false
            return &quot;SUCCESS: $($f.Name)&quot;
        } catch {
            return &quot;ERROR: $($f.Name) -&gt; $($_.Exception.Message)&quot;
        }
    } -ArgumentList $file, $destUrl, $targetFolderPath
}
</pre></div>

<div class="wp-block-image">
<figure class="aligncenter size-full is-resized"><img loading="lazy" decoding="async" width="520" height="330" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image.png" alt="" class="wp-image-43806" style="width:522px;height:auto" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image.png 520w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/04/image-300x190.png 300w" sizes="auto, (max-width: 520px) 100vw, 520px" /></figure>
</div>


<p><strong>Note :</strong> The <code><a href="https://www.powershelladmin.com/wiki/PowerShell_foreach_loops_and_ForEach-Object.php" id="https://www.powershelladmin.com/wiki/PowerShell_foreach_loops_and_ForEach-Object.php">-Parallel</a></code> parameter is a feature of the <code>ForEach-Object</code> cmdlet introduced in PowerShell 7 to enable native multi-threading. While it allows for processing multiple objects simultaneously, it is not reliably supported by the <code>ReportingServicesTools</code> library as the underlying API is not thread-safe. To ensure stability and avoid session collisions, it is recommended to use the <code>Start-Job</code> method instead, as it provides better process isolation for each task.</p>



<h2 class="wp-block-heading" id="h-key-takeaways-for-a-seamless-cutover">Key Takeaways for a Seamless Cutover</h2>



<p>Migrating to Power BI Report Server shouldn&#8217;t be a manual challenge. By adopting this <strong>PowerShell-driven ETL approach</strong>, you replace the uncertainty of manual intervention with industrial-grade rigor.</p>



<p>The primary advantage lies in consistency: regardless of the report volume or folder complexity, the script guarantees an identical and predictable result every single time. By isolating extraction, XML transformation, and ordered importation, you maintain total control over your data integrity.</p>



<p>Ultimately, automation is about securing your delivery and freeing up time for what truly matters: leveraging your data on your brand-new PBIRS 2025 platform.</p>



<p>Happy migrating!</p>
<p>L’article <a href="https://www.dbi-services.com/blog/scaling-ssrs-migrations-multi-threaded-automation-for-pbirs-2025/">Scaling SSRS Migrations: Multi-Threaded Automation for PBIRS 2025</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/scaling-ssrs-migrations-multi-threaded-automation-for-pbirs-2025/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>How to Standardize SQL Server Disks on VMs using Ansible</title>
		<link>https://www.dbi-services.com/blog/how-to-standardize-sql-server-disks-on-vms-using-ansible/</link>
					<comments>https://www.dbi-services.com/blog/how-to-standardize-sql-server-disks-on-vms-using-ansible/#respond</comments>
		
		<dc:creator><![CDATA[Nathan Courtine]]></dc:creator>
		<pubDate>Mon, 23 Mar 2026 17:50:46 +0000</pubDate>
				<category><![CDATA[Ansible]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[Automation]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=43534</guid>

					<description><![CDATA[<p>INTRODUCTION Today, the benefits of automation no longer need much explanation: saving time, reducing human error, and ensuring every environment remains aligned with internal standards. What is less obvious, however, is how using an Ansible Playbook can provide advantages that more traditional scripting approaches — such as large PowerShell scripts — struggle to offer. That [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/how-to-standardize-sql-server-disks-on-vms-using-ansible/">How to Standardize SQL Server Disks on VMs using Ansible</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 class="wp-block-heading" id="h-introduction">INTRODUCTION</h2>



<p>Today, the benefits of automation no longer need much explanation: saving time, reducing human error, and ensuring every environment remains aligned with internal standards. What is less obvious, however, is how using an Ansible Playbook can provide advantages that more traditional scripting approaches — such as large PowerShell scripts — struggle to offer. That is exactly what I want to explore here.</p>



<p>When you complete an automated deployment of a SQL Server environment on Windows Server, there is a real sense of achievement. You have invested time and effort, and you expect that investment to pay off thanks to the reliability and repeatability of automation.</p>



<p>But everything changes when the next Windows Server upgrade or SQL Server version arrives… or when corporate standards evolve. Suddenly, you need to reopen a multi-thousand‑line PowerShell script and:</p>



<ul class="wp-block-list">
<li>Integrate the required changes while keeping execution stable,</li>



<li>Avoid subtle but potentially critical regressions,</li>



<li>Maintain clear and usable logging,</li>



<li>Retest the entire automation workflow,</li>



<li>Troubleshoot new issues introduced by the modifications.</li>
</ul>



<p>This is precisely the type of situation where Ansible becomes a far better long‑term investment. Its architecture and philosophy offer several advantages:</p>



<ul class="wp-block-list">
<li>Native idempotence, ensuring the same result even after multiple runs,</li>



<li>A declarative YAML approach, focusing on the desired end state rather than the execution steps,</li>



<li>Windows Server and SQL Server modules, providing built‑in idempotence and saving significant time,</li>



<li>Agentless connectivity, simplifying deployment on new machines,</li>



<li>A modular structure (roles, modules, variables), making adaptation and reuse of your automation much easier.</li>
</ul>



<p>In this article, I will give you a concrete overview by walking you through how to configure the disks required for SQL Server using Ansible.</p>



<h2 class="wp-block-heading" id="h-1-map-iscsi-controllers-to-disk-numbers">1-Map iSCSI controllers to disk numbers</h2>



<p>When developing an Ansible Playbook, one fundamental principle is to design for idempotence from the very start—not just rely on idempotent modules.</p>



<p>On Windows, disk numbering is not guaranteed: it depends on several factors – how disks are detected at startup, the firmware, and so on.<br>As a result, disk numbers may change from one reboot to another.</p>



<p>To ensure consistent and reliable execution of your deployment, this behavior must be accounted for directly in the design of your Playbook.<br>Otherwise, it may introduce wrong behaviors, and lead to:</p>



<ul class="wp-block-list">
<li>formatting the wrong disk,</li>



<li>mounting volumes on incorrect devices,</li>



<li>completely breaking the SQL Server provisioning workflow.</li>
</ul>



<p>In other words, idempotence is no longer guaranteed.</p>



<p>To ensure stable and predictable executions, you must determine dynamically the correct disk numbering at each execution.<br></p>



<p>You can use Get-Disk PowerShell command to achieve your goal, by searching iSCSI controller number and LUN position from Location property.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; gutter: false; title: ; notranslate">
$adapter = {{ disk.adapter }}
$lun = {{ disk.lun}}
(Get-Disk | Where-Object {
      $_.Location -match &quot;Adapter $adapter\s+:.*\s+LUN $lun&quot;
    }).number
</pre></div>


<p>We have done our mapping between VM specifications and Windows disk numbers.</p>



<h2 class="wp-block-heading" id="h-2-loop-sql-server-disks">2-Loop SQL Server disks</h2>



<p>Since we often have several disks to configure — Data, Logs, TempDB — we need to perform the same actions repeatedly on each disk:</p>



<ul class="wp-block-list">
<li>dynamically determine the disk number,</li>



<li>initialize it in GPT,</li>



<li>create the partition and format the volume in NTFS with a 64 KB allocation unit size,</li>



<li>assign an access path (drive letter or mountpoint),</li>



<li>apply certain specific configuration settings, such as disabling indexing,</li>



<li>verify the compliance of the disk configuration.</li>
</ul>



<p>As these actions are identical for all disks, the best approach is to factorize the tasks.<br>The Ansible pattern, for such scenario, is to loop that call in a dedicated Task File.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
---

- name: Manage all disk properties based on Location and Target numbers
  ansible.builtin.include_tasks: disks_properties.yml
  loop:
    - name: data
      location: &quot;{{ disk_specs.data.location }}&quot;
      target: &quot;{{ disk_specs.data.target }}&quot;
      label: &quot;{{ disk_specs.data.label }}&quot;
      letter: &quot;{{ disk_specs.data.letter }}&quot;
    - name: logs
      location: &quot;{{ disk_specs.logs.location }}&quot;
      target: &quot;{{ disk_specs.logs.target }}&quot;
      label: &quot;{{ disk_specs.logs.label }}&quot;
      letter: &quot;{{ disk_specs.logs.letter }}&quot;
    - name: tempdb
      location: &quot;{{ disk_specs.tempdb.location }}&quot;
      target: &quot;{{ disk_specs.tempdb.target }}&quot;
      label: &quot;{{ disk_specs.tempdb.label }}&quot;
      letter: &quot;{{ disk_specs.tempdb.letter }}&quot;
  loop_control:
    loop_var: disk

...
</pre></div>


<h2 class="wp-block-heading" id="h-3-implement-sql-server-disk-configuration">3- Implement SQL Server disk configuration</h2>



<p>Since we performed our loop in the previous section on the disks_properties.yml file, we can now implement the configuration actions inside this file.<br>First, we will retrieve the disk number and then begin configuring the disk according to best practices and our internal standards.</p>



<p>To guarantee idempotence, we will mark this step as not changed: this is only a Get action:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
---

- name: Identify the {{ disk.name }} disk number
  ansible.windows.win_shell: |
    $adapter = {{ disk.target }}
    $lun = {{ disk.location }}
    (Get-Disk | Where-Object {
      $_.Location -match &quot;Adapter $adapter\s+:.*\s+LUN $lun&quot;
    }).number
  register: disk_num
  changed_when: false
</pre></div>


<p>Then, we will register the disk number as an Ansible Fact for all this task file execution call.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
- name: Set fact for {{ disk.name }} disk number
  ansible.builtin.set_fact:
    &quot;disk_number_{{ disk.name }}&quot;: &quot;{{ disk_num.stdout | trim | int }}&quot;
</pre></div>


<p>We can now initialize the disk using <em>community.windows</em> module. Of course, use Ansible module if possible.</p>



<p>The parameter <em>disk_bps.partition_style</em> is a variable of my Ansible Role, to guarantee GPT will be used.<br></p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
- name: Initialize disks
  community.windows.win_initialize_disk:
    disk_number: &quot;{{ lookup(&#039;vars&#039;, &#039;disk_number_&#039; + disk.name) }}&quot;
    style: &quot;{{ disk_bps.partition_style }}&quot;
</pre></div>


<p>From there, we can create our partition: </p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
- name: Create partition with letter {{ disk.letter }} for disk {{ disk.name }}
  community.windows.win_partition:
    drive_letter: &quot;{{ disk.letter }}&quot;
    partition_size: &quot;-1&quot;
    disk_number: &quot;{{ lookup(&#039;vars&#039;, &#039;disk_number_&#039; + disk.name) }}&quot;
</pre></div>


<p>And now format our volume with allocation unit size 64KB:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
- name: Create a partition letter {{ disk.letter }} on disk {{ disk.name }} with label {{ disk.label }}
  community.windows.win_format:
    drive_letter: &quot;{{ disk.letter }}&quot;
    allocation_unit_size: &quot;{{ disk_bps.allocation_unit_size_bytes }}&quot;
    new_label: &quot;{{ disk.label }}&quot;

...
</pre></div>


<p>As I mentioned earlier in previous section, we can also add tasks relative to some specific standards or a tasks to guarantee disk compliance.</p>



<h2 class="wp-block-heading" id="h-4-execute-the-playbook">4- Execute the Playbook</h2>



<p>Now that our Ansible Role <em>windows_disks</em> is ready, we can call it through a Playbook.<br>Of course, we must adjust the reality of the iSCSI configuration of the Virtual Machine.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
---

- name: Configure Disks by detecting Disk Number
  hosts: Raynor
  gather_facts: false
  vars:
    disk_specs:
      data:
        location: 0
        target: 1
        label: SQL_DATA
        letter: E
      logs:
        location: 0
        target: 2
        label: SQL_TLOG
        letter: L
      tempdb:
        location: 0
        target: 3
        label: SQL_TEMPDB
        letter: T
  tasks:
    - name: gather facts
      ansible.builtin.setup:
      changed_when: false
      tags: &#x5B;always]
    - name: Configure Disks
      ansible.builtin.import_role:
        name: windows_disks
      tags: windows_disks


...
</pre></div>


<figure data-wp-context="{&quot;imageId&quot;:&quot;6a11e48df0513&quot;}" data-wp-interactive="core/image" data-wp-key="6a11e48df0513" class="wp-block-image size-large wp-lightbox-container"><img loading="lazy" decoding="async" width="1024" height="623" data-wp-class--hide="state.isContentHidden" data-wp-class--show="state.isContentVisible" data-wp-init="callbacks.setButtonStyles" data-wp-on--click="actions.showLightbox" data-wp-on--load="callbacks.setButtonStyles" data-wp-on-window--resize="callbacks.setButtonStyles" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/03/Ansible_disks-1024x623.png" alt="" class="wp-image-43551" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/03/Ansible_disks-1024x623.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/03/Ansible_disks-300x182.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/03/Ansible_disks-768x467.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/03/Ansible_disks.png 1329w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /><button
			class="lightbox-trigger"
			type="button"
			aria-haspopup="dialog"
			aria-label="Enlarge"
			data-wp-init="callbacks.initTriggerButton"
			data-wp-on--click="actions.showLightbox"
			data-wp-style--right="state.imageButtonRight"
			data-wp-style--top="state.imageButtonTop"
		>
			<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12">
				<path fill="#fff" d="M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z" />
			</svg>
		</button></figure>



<h2 class="wp-block-heading" id="h-conclusion">CONCLUSION</h2>



<p>We have had an overview of how Ansible makes automation easier to maintain and to evolve, by focusing on the logic of our deployment and not on the code to achieve it.<br>Now, updating your standards or upgrading versions will no longer require rewriting scripts, but mainly adapting variables.</p>



<p>However, it is important to be aware that idempotence must also be maintained through design.<br></p>
<p>L’article <a href="https://www.dbi-services.com/blog/how-to-standardize-sql-server-disks-on-vms-using-ansible/">How to Standardize SQL Server Disks on VMs using Ansible</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/how-to-standardize-sql-server-disks-on-vms-using-ansible/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 
Lazy Loading (feed)

Served from: www.dbi-services.com @ 2026-05-23 19:31:58 by W3 Total Cache
-->